Add public feed, admin area, self-registration, visibility & hashtags
Some checks failed
Deploy to NAS / deploy (push) Failing after 26s
Some checks failed
Deploy to NAS / deploy (push) Failing after 26s
- Public feed (/) with infinite scroll via Intersection Observer - Self-registration (/register) - Admin area (/admin/entries, /admin/users) with user management - journal_entries: visibility (public/private) + hashtags fields - users: is_admin flag - DB schema updated (recreate DB to apply) - CI: run go test via docker run (golang:1.25-alpine) — fixes 'go not found' Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -24,7 +24,12 @@ jobs:
|
|||||||
run: printf 'DB_PASSWORD=%s\n' '${{ secrets.DB_PASSWORD }}' > ${{ vars.DEPLOY_DIR }}/.env
|
run: printf 'DB_PASSWORD=%s\n' '${{ secrets.DB_PASSWORD }}' > ${{ vars.DEPLOY_DIR }}/.env
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: cd ${{ vars.DEPLOY_DIR }}/backend && go test ./...
|
run: |
|
||||||
|
docker run --rm \
|
||||||
|
-v ${{ vars.DEPLOY_DIR }}/backend:/app \
|
||||||
|
-w /app \
|
||||||
|
golang:1.25-alpine \
|
||||||
|
go test ./...
|
||||||
|
|
||||||
- name: Build & Deploy
|
- name: Build & Deploy
|
||||||
run: docker compose -f ${{ vars.DEPLOY_DIR }}/docker-compose.yml up --build -d
|
run: docker compose -f ${{ vars.DEPLOY_DIR }}/docker-compose.yml up --build -d
|
||||||
|
|||||||
@@ -49,8 +49,9 @@ func main() {
|
|||||||
stopStore := db.NewStopStore(pool)
|
stopStore := db.NewStopStore(pool)
|
||||||
suggStore := db.NewSuggestionStore(pool)
|
suggStore := db.NewSuggestionStore(pool)
|
||||||
journalStore := db.NewJournalStore(pool)
|
journalStore := db.NewJournalStore(pool)
|
||||||
|
userStore := db.NewUserStore(pool)
|
||||||
|
|
||||||
router := api.NewRouter(authStore, tpStore, stopStore, suggStore, journalStore, uploadDir)
|
router := api.NewRouter(authStore, tpStore, stopStore, suggStore, journalStore, userStore, uploadDir)
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
|
|||||||
@@ -46,6 +46,20 @@ func (h *JournalHandler) HandleCreateEntry(w http.ResponseWriter, r *http.Reques
|
|||||||
entryTime := strings.TrimSpace(r.FormValue("time"))
|
entryTime := strings.TrimSpace(r.FormValue("time"))
|
||||||
title := strings.TrimSpace(r.FormValue("title"))
|
title := strings.TrimSpace(r.FormValue("title"))
|
||||||
description := strings.TrimSpace(r.FormValue("description"))
|
description := strings.TrimSpace(r.FormValue("description"))
|
||||||
|
visibility := r.FormValue("visibility")
|
||||||
|
if visibility != "public" && visibility != "private" {
|
||||||
|
visibility = "private"
|
||||||
|
}
|
||||||
|
var hashtags []string
|
||||||
|
if raw := strings.TrimSpace(r.FormValue("hashtags")); raw != "" {
|
||||||
|
for _, tag := range strings.Split(raw, ",") {
|
||||||
|
tag = strings.TrimSpace(tag)
|
||||||
|
tag = strings.TrimPrefix(tag, "#")
|
||||||
|
if tag != "" {
|
||||||
|
hashtags = append(hashtags, tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if date == "" || entryTime == "" {
|
if date == "" || entryTime == "" {
|
||||||
http.Error(w, "Datum und Uhrzeit sind Pflichtfelder", http.StatusBadRequest)
|
http.Error(w, "Datum und Uhrzeit sind Pflichtfelder", http.StatusBadRequest)
|
||||||
@@ -58,6 +72,8 @@ func (h *JournalHandler) HandleCreateEntry(w http.ResponseWriter, r *http.Reques
|
|||||||
EntryTime: entryTime,
|
EntryTime: entryTime,
|
||||||
Title: title,
|
Title: title,
|
||||||
Description: description,
|
Description: description,
|
||||||
|
Visibility: visibility,
|
||||||
|
Hashtags: hashtags,
|
||||||
}
|
}
|
||||||
|
|
||||||
if lat := r.FormValue("lat"); lat != "" {
|
if lat := r.FormValue("lat"); lat != "" {
|
||||||
|
|||||||
@@ -5,39 +5,72 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/jacek/pamietnik/backend/internal/auth"
|
"github.com/jacek/pamietnik/backend/internal/auth"
|
||||||
|
"github.com/jacek/pamietnik/backend/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
type contextKey string
|
type contextKey string
|
||||||
|
|
||||||
const ctxUserID contextKey = "user_id"
|
const ctxUserID contextKey = "user_id"
|
||||||
|
const ctxUser contextKey = "user"
|
||||||
|
|
||||||
const sessionCookieName = "session"
|
const sessionCookieName = "session"
|
||||||
|
|
||||||
// RequireAuth is a middleware that validates the session cookie.
|
// RequireAuth validates the session cookie and stores user info in context.
|
||||||
|
// On failure it redirects to /login for browser requests (text/html) or returns JSON 401.
|
||||||
func RequireAuth(authStore *auth.Store) func(http.Handler) http.Handler {
|
func RequireAuth(authStore *auth.Store) func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
cookie, err := r.Cookie(sessionCookieName)
|
user, err := userFromRequest(r, authStore)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "login required")
|
redirectOrUnauthorized(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sess, err := authStore.GetSession(r.Context(), cookie.Value)
|
ctx := context.WithValue(r.Context(), ctxUserID, user.UserID)
|
||||||
if err != nil {
|
ctx = context.WithValue(ctx, ctxUser, user)
|
||||||
writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "invalid or expired session")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx := context.WithValue(r.Context(), ctxUserID, sess.UserID)
|
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// requireAdmin checks that the authenticated user is an admin.
|
||||||
|
func requireAdmin(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
u, ok := r.Context().Value(ctxUser).(domain.User)
|
||||||
|
if !ok || !u.IsAdmin {
|
||||||
|
http.Redirect(w, r, "/days", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func userFromRequest(r *http.Request, authStore *auth.Store) (domain.User, error) {
|
||||||
|
cookie, err := r.Cookie(sessionCookieName)
|
||||||
|
if err != nil {
|
||||||
|
return domain.User{}, auth.ErrSessionNotFound
|
||||||
|
}
|
||||||
|
return authStore.GetUserBySession(r.Context(), cookie.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func redirectOrUnauthorized(w http.ResponseWriter, r *http.Request) {
|
||||||
|
accept := r.Header.Get("Accept")
|
||||||
|
if len(accept) > 0 && (accept == "application/json" || r.Header.Get("X-Requested-With") == "XMLHttpRequest") {
|
||||||
|
writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "login required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
func userIDFromContext(ctx context.Context) string {
|
func userIDFromContext(ctx context.Context) string {
|
||||||
v, _ := ctx.Value(ctxUserID).(string)
|
v, _ := ctx.Value(ctxUserID).(string)
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func userFromContext(ctx context.Context) domain.User {
|
||||||
|
v, _ := ctx.Value(ctxUser).(domain.User)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
func contextWithUserID(ctx context.Context, userID string) context.Context {
|
func contextWithUserID(ctx context.Context, userID string) context.Context {
|
||||||
return context.WithValue(ctx, ctxUserID, userID)
|
return context.WithValue(ctx, ctxUserID, userID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ func NewRouter(
|
|||||||
stopStore *db.StopStore,
|
stopStore *db.StopStore,
|
||||||
suggStore *db.SuggestionStore,
|
suggStore *db.SuggestionStore,
|
||||||
journalStore *db.JournalStore,
|
journalStore *db.JournalStore,
|
||||||
|
userStore *db.UserStore,
|
||||||
uploadDir string,
|
uploadDir string,
|
||||||
) http.Handler {
|
) http.Handler {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
@@ -23,10 +24,9 @@ func NewRouter(
|
|||||||
r.Use(middleware.Logger)
|
r.Use(middleware.Logger)
|
||||||
r.Use(middleware.Recoverer)
|
r.Use(middleware.Recoverer)
|
||||||
|
|
||||||
webUI := NewWebUI(authStore, tpStore, stopStore, journalStore)
|
webUI := NewWebUI(authStore, tpStore, stopStore, journalStore, userStore)
|
||||||
journalHandler := NewJournalHandler(journalStore, uploadDir)
|
journalHandler := NewJournalHandler(journalStore, uploadDir)
|
||||||
authMW := RequireAuth(authStore)
|
authMW := RequireAuth(authStore)
|
||||||
webAuthMW := requireWebAuth(authStore)
|
|
||||||
|
|
||||||
// Health
|
// Health
|
||||||
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -55,19 +55,37 @@ func NewRouter(
|
|||||||
// Static assets (CSS etc.)
|
// Static assets (CSS etc.)
|
||||||
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS()))))
|
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS()))))
|
||||||
|
|
||||||
// Web UI
|
// Public routes (no auth required)
|
||||||
|
r.Get("/", webUI.HandleFeed)
|
||||||
|
r.Get("/feed", webUI.HandleFeedFragment)
|
||||||
|
r.Get("/register", webUI.HandleGetRegister)
|
||||||
|
r.Post("/register", webUI.HandlePostRegister)
|
||||||
r.Get("/login", webUI.HandleGetLogin)
|
r.Get("/login", webUI.HandleGetLogin)
|
||||||
r.Post("/login", webUI.HandlePostLogin)
|
r.Post("/login", webUI.HandlePostLogin)
|
||||||
r.Post("/logout", webUI.HandleLogout)
|
r.Post("/logout", webUI.HandleLogout)
|
||||||
|
|
||||||
|
// Authenticated web routes
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(webAuthMW)
|
r.Use(authMW)
|
||||||
r.Get("/days", webUI.HandleDaysList)
|
r.Get("/days", webUI.HandleDaysList)
|
||||||
r.Get("/days/redirect", webUI.HandleDaysRedirect)
|
r.Get("/days/redirect", webUI.HandleDaysRedirect)
|
||||||
r.Get("/days/{date}", webUI.HandleDayDetail)
|
r.Get("/days/{date}", webUI.HandleDayDetail)
|
||||||
r.Post("/entries", journalHandler.HandleCreateEntry)
|
r.Post("/entries", journalHandler.HandleCreateEntry)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Admin routes
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(authMW)
|
||||||
|
r.Use(requireAdmin)
|
||||||
|
r.Get("/admin", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, "/admin/entries", http.StatusSeeOther)
|
||||||
|
})
|
||||||
|
r.Get("/admin/entries", webUI.HandleAdminEntries)
|
||||||
|
r.Get("/admin/users", webUI.HandleAdminUsers)
|
||||||
|
r.Post("/admin/users", webUI.HandleAdminCreateUser)
|
||||||
|
r.Delete("/admin/users/{id}", webUI.HandleAdminDeleteUser)
|
||||||
|
})
|
||||||
|
|
||||||
// Serve uploaded images
|
// Serve uploaded images
|
||||||
r.Handle("/uploads/*", http.StripPrefix("/uploads/", http.FileServer(http.Dir(uploadDir))))
|
r.Handle("/uploads/*", http.StripPrefix("/uploads/", http.FileServer(http.Dir(uploadDir))))
|
||||||
|
|
||||||
@@ -76,31 +94,5 @@ func NewRouter(
|
|||||||
r.Handle(spaPrefix, http.RedirectHandler(spaPrefix+"/", http.StatusMovedPermanently))
|
r.Handle(spaPrefix, http.RedirectHandler(spaPrefix+"/", http.StatusMovedPermanently))
|
||||||
r.Handle(spaPrefix+"/*", http.StripPrefix(spaPrefix, SPAHandler(spaPrefix)))
|
r.Handle(spaPrefix+"/*", http.StripPrefix(spaPrefix, SPAHandler(spaPrefix)))
|
||||||
|
|
||||||
// Redirect root to Go Web UI /days
|
|
||||||
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
http.Redirect(w, r, "/days", http.StatusSeeOther)
|
|
||||||
})
|
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
// requireWebAuth redirects to /login for unauthenticated web users (HTML response).
|
|
||||||
func requireWebAuth(authStore *auth.Store) func(http.Handler) http.Handler {
|
|
||||||
return func(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
cookie, err := r.Cookie(sessionCookieName)
|
|
||||||
if err != nil {
|
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sess, err := authStore.GetSession(r.Context(), cookie.Value)
|
|
||||||
if err != nil {
|
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx := r.Context()
|
|
||||||
ctx = contextWithUserID(ctx, sess.UserID)
|
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -52,3 +52,17 @@ h2 { font-size: 1rem; font-weight: normal; letter-spacing: .05em; }
|
|||||||
|
|
||||||
/* Login */
|
/* Login */
|
||||||
.login-box { max-width: 360px; margin: 4rem auto; }
|
.login-box { max-width: 360px; margin: 4rem auto; }
|
||||||
|
|
||||||
|
/* Error message */
|
||||||
|
.error { color: #c44; }
|
||||||
|
|
||||||
|
/* Hashtags */
|
||||||
|
.hashtags { margin-top: .3rem; }
|
||||||
|
.tag { font-size: .75rem; background: var(--pico-muted-background-color); padding: .1rem .4rem; border-radius: 999px; margin-right: .2rem; }
|
||||||
|
|
||||||
|
/* Visibility badge */
|
||||||
|
.badge-public { font-size: .7rem; background: #264; color: #8f8; padding: .1rem .4rem; border-radius: 4px; vertical-align: middle; }
|
||||||
|
|
||||||
|
/* Delete button */
|
||||||
|
.btn-delete { background: none; border: 1px solid #c44; color: #c44; padding: .2rem .6rem; cursor: pointer; font-size: .8rem; border-radius: 4px; }
|
||||||
|
.btn-delete:hover { background: #c44; color: #fff; }
|
||||||
|
|||||||
33
backend/internal/api/templates/admin/entries.html
Normal file
33
backend/internal/api/templates/admin/entries.html
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{{define "admin_title"}}Einträge verwalten — Admin{{end}}
|
||||||
|
|
||||||
|
{{define "admin_content"}}
|
||||||
|
<h1>Einträge</h1>
|
||||||
|
<p><a href="/days">→ Neuen Eintrag anlegen (Tagesansicht)</a></p>
|
||||||
|
|
||||||
|
{{if .Entries}}
|
||||||
|
<figure>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Datum</th><th>Zeit</th><th>Titel</th><th>Sichtbarkeit</th><th>Hashtags</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Entries}}
|
||||||
|
<tr>
|
||||||
|
<td><a href="/days/{{.EntryDate}}">{{.EntryDate}}</a></td>
|
||||||
|
<td>{{.EntryTime}}</td>
|
||||||
|
<td>{{if .Title}}{{.Title}}{{else}}<small>—</small>{{end}}</td>
|
||||||
|
<td>
|
||||||
|
{{if eq .Visibility "public"}}
|
||||||
|
<span class="badge-public">öffentlich</span>
|
||||||
|
{{else}}
|
||||||
|
<small>privat</small>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
<td><small>{{join .Hashtags ", "}}</small></td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</figure>
|
||||||
|
{{else}}
|
||||||
|
<p><small>// Noch keine Einträge</small></p>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
25
backend/internal/api/templates/admin/layout.html
Normal file
25
backend/internal/api/templates/admin/layout.html
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{{define "admin_base"}}<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{block "admin_title" .}}Admin{{end}}</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.classless.slate.min.css">
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="container">
|
||||||
|
<nav>
|
||||||
|
<strong>Admin</strong>
|
||||||
|
<span>
|
||||||
|
<a href="/admin/entries">Einträge</a> ·
|
||||||
|
<a href="/admin/users">Benutzer</a> ·
|
||||||
|
<a href="/days">← App</a>
|
||||||
|
</span>
|
||||||
|
</nav>
|
||||||
|
{{block "admin_content" .}}{{end}}
|
||||||
|
</main>
|
||||||
|
{{block "admin_scripts" .}}{{end}}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
53
backend/internal/api/templates/admin/users.html
Normal file
53
backend/internal/api/templates/admin/users.html
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
{{define "admin_title"}}Benutzer verwalten — Admin{{end}}
|
||||||
|
|
||||||
|
{{define "admin_content"}}
|
||||||
|
<h1>Benutzer</h1>
|
||||||
|
|
||||||
|
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||||
|
|
||||||
|
<form method="post" action="/admin/users" style="display:flex;gap:1rem;align-items:flex-end;flex-wrap:wrap">
|
||||||
|
<div>
|
||||||
|
<label>Benutzername</label>
|
||||||
|
<input type="text" name="username" required autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Passwort</label>
|
||||||
|
<input type="password" name="password" required autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
<button type="submit">Anlegen</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<figure>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Benutzername</th><th>Admin</th><th>Erstellt</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Users}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.Username}}</td>
|
||||||
|
<td>{{if .IsAdmin}}✓{{end}}</td>
|
||||||
|
<td><small>{{.CreatedAt.Format "2006-01-02"}}</small></td>
|
||||||
|
<td>
|
||||||
|
{{if ne .UserID $.User.UserID}}
|
||||||
|
<button class="btn-delete" data-url="/admin/users/{{.UserID}}" data-name="{{.Username}}">Löschen</button>
|
||||||
|
{{else}}
|
||||||
|
<small>(du)</small>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</figure>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "admin_scripts"}}
|
||||||
|
<script>
|
||||||
|
document.querySelectorAll('.btn-delete').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
if (!confirm('Benutzer "' + btn.dataset.name + '" löschen?')) return;
|
||||||
|
fetch(btn.dataset.url, {method: 'DELETE'})
|
||||||
|
.then(function() { window.location.reload(); });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
@@ -13,6 +13,15 @@
|
|||||||
<label>Uhrzeit</label>
|
<label>Uhrzeit</label>
|
||||||
<input type="time" name="time" required id="entry-time">
|
<input type="time" name="time" required id="entry-time">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-col">
|
||||||
|
<label>Sichtbarkeit</label>
|
||||||
|
<select name="visibility">
|
||||||
|
<option value="private">Privat</option>
|
||||||
|
<option value="public">Öffentlich</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
<div class="form-col">
|
<div class="form-col">
|
||||||
<label>GPS-Koordinaten <small>(optional)</small></label>
|
<label>GPS-Koordinaten <small>(optional)</small></label>
|
||||||
<div class="gps-row">
|
<div class="gps-row">
|
||||||
@@ -22,6 +31,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<small id="gps-status"></small>
|
<small id="gps-status"></small>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-col">
|
||||||
|
<label>Hashtags <small>(kommagetrennt, optional)</small></label>
|
||||||
|
<input type="text" name="hashtags" placeholder="reise, essen, natur">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label>Überschrift</label>
|
<label>Überschrift</label>
|
||||||
<input type="text" name="title" placeholder="Titel des Eintrags">
|
<input type="text" name="title" placeholder="Titel des Eintrags">
|
||||||
@@ -38,10 +51,12 @@
|
|||||||
<div class="entry-card">
|
<div class="entry-card">
|
||||||
<div class="entry-meta">
|
<div class="entry-meta">
|
||||||
<strong>{{.EntryTime}}</strong>
|
<strong>{{.EntryTime}}</strong>
|
||||||
|
{{if eq .Visibility "public"}}<span class="badge-public">öffentlich</span>{{end}}
|
||||||
{{if .Lat}}<small> · ○ {{printf "%.5f" (deref .Lat)}}, {{printf "%.5f" (deref .Lon)}}</small>{{end}}
|
{{if .Lat}}<small> · ○ {{printf "%.5f" (deref .Lat)}}, {{printf "%.5f" (deref .Lon)}}</small>{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{if .Title}}<div class="entry-title">{{.Title}}</div>{{end}}
|
{{if .Title}}<div class="entry-title">{{.Title}}</div>{{end}}
|
||||||
{{if .Description}}<div class="entry-desc">{{.Description}}</div>{{end}}
|
{{if .Description}}<div class="entry-desc">{{.Description}}</div>{{end}}
|
||||||
|
{{if .Hashtags}}<div class="hashtags">{{range .Hashtags}}<span class="tag">#{{.}}</span> {{end}}</div>{{end}}
|
||||||
{{if .Images}}
|
{{if .Images}}
|
||||||
<div class="entry-images">
|
<div class="entry-images">
|
||||||
{{range .Images}}
|
{{range .Images}}
|
||||||
@@ -56,27 +71,8 @@
|
|||||||
<p><small>// Noch keine Einträge</small></p>
|
<p><small>// Noch keine Einträge</small></p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<h2>Trackpunkte <small>({{len .Points}})</small></h2>
|
|
||||||
<figure>
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>Zeit</th><th>Lat</th><th>Lon</th><th>Quelle</th><th>Notiz</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{{range .Points}}
|
|
||||||
<tr>
|
|
||||||
<td>{{.Timestamp.Format "15:04:05"}}</td>
|
|
||||||
<td>{{printf "%.5f" .Lat}}</td>
|
|
||||||
<td>{{printf "%.5f" .Lon}}</td>
|
|
||||||
<td class="source-{{.Source}}">{{.Source}}</td>
|
|
||||||
<td><small>{{.Note}}</small></td>
|
|
||||||
</tr>
|
|
||||||
{{else}}
|
|
||||||
<tr><td colspan="5"><small>// Keine Punkte</small></td></tr>
|
|
||||||
{{end}}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</figure>
|
|
||||||
|
|
||||||
<h2>Aufenthalte <small>({{len .Stops}})</small></h2>
|
<h2>Aufenthalte <small>({{len .Stops}})</small></h2>
|
||||||
|
{{if .Stops}}
|
||||||
<figure>
|
<figure>
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>Von</th><th>Bis</th><th>Dauer</th><th>Ort</th></tr></thead>
|
<thead><tr><th>Von</th><th>Bis</th><th>Dauer</th><th>Ort</th></tr></thead>
|
||||||
@@ -88,12 +84,34 @@
|
|||||||
<td><small>{{divInt .DurationS 60}} min</small></td>
|
<td><small>{{divInt .DurationS 60}} min</small></td>
|
||||||
<td>{{if .PlaceLabel}}{{.PlaceLabel}}{{else}}<small>—</small>{{end}}</td>
|
<td>{{if .PlaceLabel}}{{.PlaceLabel}}{{else}}<small>—</small>{{end}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{else}}
|
|
||||||
<tr><td colspan="4"><small>// Keine Aufenthalte</small></td></tr>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</figure>
|
</figure>
|
||||||
|
{{else}}
|
||||||
|
<p><small>// Keine Aufenthalte</small></p>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><small>Trackpunkte ({{len .Points}})</small></summary>
|
||||||
|
<figure>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Zeit</th><th>Lat</th><th>Lon</th><th>Quelle</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Points}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.Timestamp.Format "15:04:05"}}</td>
|
||||||
|
<td>{{printf "%.5f" .Lat}}</td>
|
||||||
|
<td>{{printf "%.5f" .Lon}}</td>
|
||||||
|
<td class="source-{{.Source}}">{{.Source}}</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr><td colspan="4"><small>// Keine Punkte</small></td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</figure>
|
||||||
|
</details>
|
||||||
</main>
|
</main>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,10 @@
|
|||||||
<main class="container">
|
<main class="container">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1>REISEJOURNAL</h1>
|
<h1>REISEJOURNAL</h1>
|
||||||
|
<span>
|
||||||
|
{{if .IsAdmin}}<a href="/admin">[ Admin ]</a> · {{end}}
|
||||||
<a href="/logout">[ Ausloggen ]</a>
|
<a href="/logout">[ Ausloggen ]</a>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<form method="get" action="/days/redirect">
|
<form method="get" action="/days/redirect">
|
||||||
<fieldset role="group">
|
<fieldset role="group">
|
||||||
|
|||||||
72
backend/internal/api/templates/public.html
Normal file
72
backend/internal/api/templates/public.html
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
{{define "title"}}Journal — Öffentliche Einträge{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<main class="container">
|
||||||
|
<nav>
|
||||||
|
<strong>Journal</strong>
|
||||||
|
<a href="/login">Anmelden</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div id="feed">
|
||||||
|
{{template "feed_items" .}}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "feed_items"}}
|
||||||
|
{{range .Entries}}
|
||||||
|
<article class="entry-card">
|
||||||
|
<header>
|
||||||
|
<small>{{.EntryDate}} · {{.EntryTime}}</small>
|
||||||
|
{{if .Title}}<strong> · {{.Title}}</strong>{{end}}
|
||||||
|
</header>
|
||||||
|
{{if .Description}}<p>{{.Description}}</p>{{end}}
|
||||||
|
{{if .Images}}
|
||||||
|
<div class="entry-images">
|
||||||
|
{{range .Images}}
|
||||||
|
<a href="/uploads/{{.Filename}}" target="_blank">
|
||||||
|
<img src="/uploads/{{.Filename}}" alt="{{.OriginalName}}" class="thumb">
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{if .Hashtags}}
|
||||||
|
<footer class="hashtags">
|
||||||
|
{{range .Hashtags}}<span class="tag">#{{.}}</span> {{end}}
|
||||||
|
</footer>
|
||||||
|
{{end}}
|
||||||
|
</article>
|
||||||
|
{{else}}
|
||||||
|
<p><small>// Noch keine öffentlichen Einträge</small></p>
|
||||||
|
{{end}}
|
||||||
|
{{if .HasMore}}
|
||||||
|
<div id="sentinel" data-offset="{{.Offset}}"></div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "scripts"}}
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
const sentinel = document.getElementById('sentinel');
|
||||||
|
if (!sentinel) return;
|
||||||
|
const obs = new IntersectionObserver(function(entries) {
|
||||||
|
if (!entries[0].isIntersecting) return;
|
||||||
|
obs.disconnect();
|
||||||
|
const offset = sentinel.dataset.offset;
|
||||||
|
fetch('/feed?offset=' + offset)
|
||||||
|
.then(r => r.text())
|
||||||
|
.then(html => {
|
||||||
|
sentinel.remove();
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.innerHTML = html;
|
||||||
|
document.getElementById('feed').append(...div.childNodes);
|
||||||
|
const next = document.getElementById('sentinel');
|
||||||
|
if (next) obs.observe(next);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
obs.observe(sentinel);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{template "base" .}}
|
||||||
20
backend/internal/api/templates/register.html
Normal file
20
backend/internal/api/templates/register.html
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{{define "title"}}Registrieren — Journal{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<main class="container" style="max-width:400px;margin-top:4rem">
|
||||||
|
<h1>Konto erstellen</h1>
|
||||||
|
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||||
|
<form method="post" action="/register">
|
||||||
|
<label>Benutzername</label>
|
||||||
|
<input type="text" name="username" value="{{.Username}}" required autofocus autocomplete="username">
|
||||||
|
<label>Passwort</label>
|
||||||
|
<input type="password" name="password" required autocomplete="new-password">
|
||||||
|
<label>Passwort bestätigen</label>
|
||||||
|
<input type="password" name="confirm" required autocomplete="new-password">
|
||||||
|
<button type="submit">Registrieren</button>
|
||||||
|
</form>
|
||||||
|
<p><small>Bereits registriert? <a href="/login">Anmelden</a></small></p>
|
||||||
|
</main>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{template "base" .}}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"io/fs"
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ var funcMap = template.FuncMap{
|
|||||||
}
|
}
|
||||||
return *p
|
return *p
|
||||||
},
|
},
|
||||||
|
"join": strings.Join,
|
||||||
}
|
}
|
||||||
|
|
||||||
var tmpls = template.Must(
|
var tmpls = template.Must(
|
||||||
@@ -47,17 +49,14 @@ type WebUI struct {
|
|||||||
tpStore *db.TrackpointStore
|
tpStore *db.TrackpointStore
|
||||||
stopStore *db.StopStore
|
stopStore *db.StopStore
|
||||||
journalStore *db.JournalStore
|
journalStore *db.JournalStore
|
||||||
|
userStore *db.UserStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewWebUI(a *auth.Store, tp *db.TrackpointStore, st *db.StopStore, j *db.JournalStore) *WebUI {
|
func NewWebUI(a *auth.Store, tp *db.TrackpointStore, st *db.StopStore, j *db.JournalStore, u *db.UserStore) *WebUI {
|
||||||
return &WebUI{authStore: a, tpStore: tp, stopStore: st, journalStore: j}
|
return &WebUI{authStore: a, tpStore: tp, stopStore: st, journalStore: j, userStore: u}
|
||||||
}
|
}
|
||||||
|
|
||||||
func render(w http.ResponseWriter, page string, data any) {
|
func render(w http.ResponseWriter, page string, data any) {
|
||||||
// Each page defines its own blocks; "base" assembles the full document.
|
|
||||||
// We must clone and re-associate per request because ParseFS loads all
|
|
||||||
// templates into one set — ExecuteTemplate("base") picks up the blocks
|
|
||||||
// defined by the last parsed file otherwise.
|
|
||||||
t, err := tmpls.Clone()
|
t, err := tmpls.Clone()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
_, err = t.ParseFS(assets, "templates/"+page)
|
_, err = t.ParseFS(assets, "templates/"+page)
|
||||||
@@ -67,8 +66,6 @@ func render(w http.ResponseWriter, page string, data any) {
|
|||||||
http.Error(w, "Template-Fehler", http.StatusInternalServerError)
|
http.Error(w, "Template-Fehler", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Render into buffer first so we can still send a proper error status
|
|
||||||
// if execution fails — once we write to w the status code is committed.
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := t.ExecuteTemplate(&buf, "base", data); err != nil {
|
if err := t.ExecuteTemplate(&buf, "base", data); err != nil {
|
||||||
slog.Error("template execute", "page", page, "err", err)
|
slog.Error("template execute", "page", page, "err", err)
|
||||||
@@ -79,6 +76,49 @@ func render(w http.ResponseWriter, page string, data any) {
|
|||||||
_, _ = buf.WriteTo(w)
|
_, _ = buf.WriteTo(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func renderFragment(w http.ResponseWriter, page, block string, data any) {
|
||||||
|
t, err := tmpls.Clone()
|
||||||
|
if err == nil {
|
||||||
|
_, err = t.ParseFS(assets, "templates/"+page)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("template parse", "page", page, "err", err)
|
||||||
|
http.Error(w, "Template-Fehler", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := t.ExecuteTemplate(&buf, block, data); err != nil {
|
||||||
|
slog.Error("template execute fragment", "page", page, "block", block, "err", err)
|
||||||
|
http.Error(w, "Template-Fehler", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_, _ = buf.WriteTo(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderAdmin(w http.ResponseWriter, page string, data any) {
|
||||||
|
// Parse layout (admin_base) + specific page fresh each request (no shared state between pages).
|
||||||
|
t, err := template.New("").Funcs(funcMap).ParseFS(assets,
|
||||||
|
"templates/admin/layout.html",
|
||||||
|
"templates/admin/"+page,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("template parse admin", "page", page, "err", err)
|
||||||
|
http.Error(w, "Template-Fehler", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := t.ExecuteTemplate(&buf, "admin_base", data); err != nil {
|
||||||
|
slog.Error("template execute admin", "page", page, "err", err)
|
||||||
|
http.Error(w, "Template-Fehler", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_, _ = buf.WriteTo(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Auth ---
|
||||||
|
|
||||||
func (ui *WebUI) HandleGetLogin(w http.ResponseWriter, r *http.Request) {
|
func (ui *WebUI) HandleGetLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
render(w, "login.html", map[string]any{"Error": "", "Username": ""})
|
render(w, "login.html", map[string]any{"Error": "", "Username": ""})
|
||||||
}
|
}
|
||||||
@@ -128,6 +168,75 @@ func (ui *WebUI) HandleLogout(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Register ---
|
||||||
|
|
||||||
|
func (ui *WebUI) HandleGetRegister(w http.ResponseWriter, r *http.Request) {
|
||||||
|
render(w, "register.html", map[string]any{"Error": "", "Username": ""})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ui *WebUI) HandlePostRegister(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "Ungültige Formulardaten", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
username := strings.TrimSpace(r.FormValue("username"))
|
||||||
|
password := r.FormValue("password")
|
||||||
|
confirm := r.FormValue("confirm")
|
||||||
|
|
||||||
|
if username == "" || password == "" {
|
||||||
|
render(w, "register.html", map[string]any{"Error": "Benutzername und Passwort sind Pflichtfelder.", "Username": username})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if password != confirm {
|
||||||
|
render(w, "register.html", map[string]any{"Error": "Passwörter stimmen nicht überein.", "Username": username})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ui.authStore.Register(r.Context(), username, password); err != nil {
|
||||||
|
msg := "Interner Fehler."
|
||||||
|
if errors.Is(err, auth.ErrUsernameTaken) {
|
||||||
|
msg = "Benutzername bereits vergeben."
|
||||||
|
}
|
||||||
|
render(w, "register.html", map[string]any{"Error": msg, "Username": username})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Public Feed ---
|
||||||
|
|
||||||
|
func (ui *WebUI) HandleFeed(w http.ResponseWriter, r *http.Request) {
|
||||||
|
entries, err := ui.journalStore.ListPublic(r.Context(), 20, 0)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Fehler beim Laden", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
render(w, "public.html", map[string]any{
|
||||||
|
"Entries": entries,
|
||||||
|
"Offset": 20,
|
||||||
|
"HasMore": len(entries) == 20,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ui *WebUI) HandleFeedFragment(w http.ResponseWriter, r *http.Request) {
|
||||||
|
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
entries, err := ui.journalStore.ListPublic(r.Context(), 20, offset)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Fehler beim Laden", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
renderFragment(w, "public.html", "feed_items", map[string]any{
|
||||||
|
"Entries": entries,
|
||||||
|
"Offset": offset + 20,
|
||||||
|
"HasMore": len(entries) == 20,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Days ---
|
||||||
|
|
||||||
func (ui *WebUI) HandleDaysRedirect(w http.ResponseWriter, r *http.Request) {
|
func (ui *WebUI) HandleDaysRedirect(w http.ResponseWriter, r *http.Request) {
|
||||||
date := strings.TrimSpace(r.URL.Query().Get("date"))
|
date := strings.TrimSpace(r.URL.Query().Get("date"))
|
||||||
if date == "" {
|
if date == "" {
|
||||||
@@ -142,16 +251,16 @@ func (ui *WebUI) HandleDaysRedirect(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (ui *WebUI) HandleDaysList(w http.ResponseWriter, r *http.Request) {
|
func (ui *WebUI) HandleDaysList(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := userIDFromContext(r.Context())
|
user := userFromContext(r.Context())
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
from := now.AddDate(-20, 0, 0).Format("2006-01-02")
|
from := now.AddDate(-20, 0, 0).Format("2006-01-02")
|
||||||
to := now.AddDate(0, 0, 1).Format("2006-01-02")
|
to := now.AddDate(0, 0, 1).Format("2006-01-02")
|
||||||
days, err := ui.tpStore.ListDays(r.Context(), userID, from, to)
|
days, err := ui.tpStore.ListDays(r.Context(), user.UserID, from, to)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Fehler beim Laden", http.StatusInternalServerError)
|
http.Error(w, "Fehler beim Laden", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
render(w, "days.html", map[string]any{"Days": days})
|
render(w, "days.html", map[string]any{"Days": days, "IsAdmin": user.IsAdmin})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ui *WebUI) HandleDayDetail(w http.ResponseWriter, r *http.Request) {
|
func (ui *WebUI) HandleDayDetail(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -186,3 +295,67 @@ func (ui *WebUI) HandleDayDetail(w http.ResponseWriter, r *http.Request) {
|
|||||||
"Entries": entries,
|
"Entries": entries,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Admin ---
|
||||||
|
|
||||||
|
func (ui *WebUI) HandleAdminEntries(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := userFromContext(r.Context())
|
||||||
|
entries, err := ui.journalStore.ListByUser(r.Context(), user.UserID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Fehler beim Laden", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
renderAdmin(w, "entries.html", map[string]any{"Entries": entries, "User": user})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ui *WebUI) HandleAdminUsers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := userFromContext(r.Context())
|
||||||
|
users, err := ui.userStore.ListUsers(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Fehler beim Laden", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
renderAdmin(w, "users.html", map[string]any{"Users": users, "User": user, "Error": ""})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ui *WebUI) HandleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "Ungültige Formulardaten", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
username := strings.TrimSpace(r.FormValue("username"))
|
||||||
|
password := r.FormValue("password")
|
||||||
|
|
||||||
|
user := userFromContext(r.Context())
|
||||||
|
|
||||||
|
if username == "" || password == "" {
|
||||||
|
users, _ := ui.userStore.ListUsers(r.Context())
|
||||||
|
renderAdmin(w, "users.html", map[string]any{"Users": users, "User": user, "Error": "Benutzername und Passwort erforderlich."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ui.authStore.Register(r.Context(), username, password); err != nil {
|
||||||
|
msg := "Interner Fehler."
|
||||||
|
if errors.Is(err, auth.ErrUsernameTaken) {
|
||||||
|
msg = "Benutzername bereits vergeben."
|
||||||
|
}
|
||||||
|
users, _ := ui.userStore.ListUsers(r.Context())
|
||||||
|
renderAdmin(w, "users.html", map[string]any{"Users": users, "User": user, "Error": msg})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ui *WebUI) HandleAdminDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
targetID := chi.URLParam(r, "id")
|
||||||
|
currentUser := userFromContext(r.Context())
|
||||||
|
if targetID == currentUser.UserID {
|
||||||
|
http.Error(w, "Eigenen Account nicht löschbar", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := ui.userStore.DeleteUser(r.Context(), targetID); err != nil {
|
||||||
|
http.Error(w, "Fehler beim Löschen", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const sessionDuration = 24 * time.Hour
|
|||||||
|
|
||||||
var ErrInvalidCredentials = errors.New("invalid username or password")
|
var ErrInvalidCredentials = errors.New("invalid username or password")
|
||||||
var ErrSessionNotFound = errors.New("session not found or expired")
|
var ErrSessionNotFound = errors.New("session not found or expired")
|
||||||
|
var ErrUsernameTaken = errors.New("username already taken")
|
||||||
|
|
||||||
type Store struct {
|
type Store struct {
|
||||||
pool *pgxpool.Pool
|
pool *pgxpool.Pool
|
||||||
@@ -128,6 +129,40 @@ func (s *Store) Logout(ctx context.Context, sessionID string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Register creates a new user account. Returns ErrUsernameTaken if the username is already in use.
|
||||||
|
func (s *Store) Register(ctx context.Context, username, password string) error {
|
||||||
|
hash, err := HashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("hash password: %w", err)
|
||||||
|
}
|
||||||
|
_, err = s.pool.Exec(ctx,
|
||||||
|
`INSERT INTO users (username, password_hash) VALUES ($1, $2)`,
|
||||||
|
username, hash,
|
||||||
|
)
|
||||||
|
if err != nil && strings.Contains(err.Error(), "unique") {
|
||||||
|
return ErrUsernameTaken
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserBySession returns the full user (including is_admin) for a session.
|
||||||
|
func (s *Store) GetUserBySession(ctx context.Context, sessionID string) (domain.User, error) {
|
||||||
|
var u domain.User
|
||||||
|
err := s.pool.QueryRow(ctx,
|
||||||
|
`SELECT u.user_id, u.username, u.is_admin, u.created_at
|
||||||
|
FROM sessions s JOIN users u ON s.user_id = u.user_id
|
||||||
|
WHERE s.session_id = $1 AND s.expires_at > NOW()`,
|
||||||
|
sessionID,
|
||||||
|
).Scan(&u.UserID, &u.Username, &u.IsAdmin, &u.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return domain.User{}, ErrSessionNotFound
|
||||||
|
}
|
||||||
|
return domain.User{}, err
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
func newSessionID() (string, error) {
|
func newSessionID() (string, error) {
|
||||||
b := make([]byte, 32)
|
b := make([]byte, 32)
|
||||||
if _, err := rand.Read(b); err != nil {
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package db
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
"github.com/jacek/pamietnik/backend/internal/domain"
|
"github.com/jacek/pamietnik/backend/internal/domain"
|
||||||
@@ -18,11 +19,17 @@ func NewJournalStore(pool *pgxpool.Pool) *JournalStore {
|
|||||||
|
|
||||||
// InsertEntry creates a new journal entry and returns it with the generated entry_id.
|
// InsertEntry creates a new journal entry and returns it with the generated entry_id.
|
||||||
func (s *JournalStore) InsertEntry(ctx context.Context, e domain.JournalEntry) (domain.JournalEntry, error) {
|
func (s *JournalStore) InsertEntry(ctx context.Context, e domain.JournalEntry) (domain.JournalEntry, error) {
|
||||||
|
if e.Visibility == "" {
|
||||||
|
e.Visibility = "private"
|
||||||
|
}
|
||||||
|
if e.Hashtags == nil {
|
||||||
|
e.Hashtags = []string{}
|
||||||
|
}
|
||||||
err := s.pool.QueryRow(ctx,
|
err := s.pool.QueryRow(ctx,
|
||||||
`INSERT INTO journal_entries (user_id, entry_date, entry_time, title, description, lat, lon)
|
`INSERT INTO journal_entries (user_id, entry_date, entry_time, title, description, lat, lon, visibility, hashtags)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
RETURNING entry_id, created_at`,
|
RETURNING entry_id, created_at`,
|
||||||
e.UserID, e.EntryDate, e.EntryTime, e.Title, e.Description, e.Lat, e.Lon,
|
e.UserID, e.EntryDate, e.EntryTime, e.Title, e.Description, e.Lat, e.Lon, e.Visibility, e.Hashtags,
|
||||||
).Scan(&e.EntryID, &e.CreatedAt)
|
).Scan(&e.EntryID, &e.CreatedAt)
|
||||||
return e, err
|
return e, err
|
||||||
}
|
}
|
||||||
@@ -41,7 +48,7 @@ func (s *JournalStore) InsertImage(ctx context.Context, img domain.JournalImage)
|
|||||||
// ListByDate returns all journal entries for a given date (YYYY-MM-DD), including their images.
|
// ListByDate returns all journal entries for a given date (YYYY-MM-DD), including their images.
|
||||||
func (s *JournalStore) ListByDate(ctx context.Context, userID, date string) ([]domain.JournalEntry, error) {
|
func (s *JournalStore) ListByDate(ctx context.Context, userID, date string) ([]domain.JournalEntry, error) {
|
||||||
rows, err := s.pool.Query(ctx,
|
rows, err := s.pool.Query(ctx,
|
||||||
`SELECT entry_id, user_id, entry_date::text, entry_time::text, title, description, lat, lon, created_at
|
`SELECT entry_id, user_id, entry_date::text, entry_time::text, title, description, lat, lon, visibility, hashtags, created_at
|
||||||
FROM journal_entries
|
FROM journal_entries
|
||||||
WHERE user_id = $1 AND entry_date = $2
|
WHERE user_id = $1 AND entry_date = $2
|
||||||
ORDER BY entry_time`,
|
ORDER BY entry_time`,
|
||||||
@@ -51,32 +58,78 @@ func (s *JournalStore) ListByDate(ctx context.Context, userID, date string) ([]d
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
entries, err := collectEntries(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.attachImages(ctx, entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPublic returns public journal entries ordered by created_at DESC, for infinite scroll.
|
||||||
|
func (s *JournalStore) ListPublic(ctx context.Context, limit, offset int) ([]domain.JournalEntry, error) {
|
||||||
|
rows, err := s.pool.Query(ctx,
|
||||||
|
`SELECT entry_id, user_id, entry_date::text, entry_time::text, title, description, lat, lon, visibility, hashtags, created_at
|
||||||
|
FROM journal_entries
|
||||||
|
WHERE visibility = 'public'
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $1 OFFSET $2`,
|
||||||
|
limit, offset,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
entries, err := collectEntries(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.attachImages(ctx, entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListByUser returns all entries for a user, ordered by entry_date DESC, entry_time DESC.
|
||||||
|
func (s *JournalStore) ListByUser(ctx context.Context, userID string) ([]domain.JournalEntry, error) {
|
||||||
|
rows, err := s.pool.Query(ctx,
|
||||||
|
`SELECT entry_id, user_id, entry_date::text, entry_time::text, title, description, lat, lon, visibility, hashtags, created_at
|
||||||
|
FROM journal_entries
|
||||||
|
WHERE user_id = $1
|
||||||
|
ORDER BY entry_date DESC, entry_time DESC`,
|
||||||
|
userID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
entries, err := collectEntries(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.attachImages(ctx, entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectEntries(rows pgx.Rows) ([]domain.JournalEntry, error) {
|
||||||
var entries []domain.JournalEntry
|
var entries []domain.JournalEntry
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var e domain.JournalEntry
|
var e domain.JournalEntry
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&e.EntryID, &e.UserID, &e.EntryDate, &e.EntryTime,
|
&e.EntryID, &e.UserID, &e.EntryDate, &e.EntryTime,
|
||||||
&e.Title, &e.Description, &e.Lat, &e.Lon, &e.CreatedAt,
|
&e.Title, &e.Description, &e.Lat, &e.Lon, &e.Visibility, &e.Hashtags, &e.CreatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
entries = append(entries, e)
|
entries = append(entries, e)
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
return entries, rows.Err()
|
||||||
return nil, err
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
// attachImages loads images for the given entries in a single query and populates .Images.
|
||||||
|
func (s *JournalStore) attachImages(ctx context.Context, entries []domain.JournalEntry) ([]domain.JournalEntry, error) {
|
||||||
if len(entries) == 0 {
|
if len(entries) == 0 {
|
||||||
return entries, nil
|
return entries, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load all images in a single query to avoid N+1
|
|
||||||
entryIDs := make([]string, len(entries))
|
entryIDs := make([]string, len(entries))
|
||||||
for i, e := range entries {
|
for i, e := range entries {
|
||||||
entryIDs[i] = e.EntryID
|
entryIDs[i] = e.EntryID
|
||||||
}
|
}
|
||||||
|
|
||||||
imgRows, err := s.pool.Query(ctx,
|
imgRows, err := s.pool.Query(ctx,
|
||||||
`SELECT image_id, entry_id, filename, original_name, mime_type, size_bytes, created_at
|
`SELECT image_id, entry_id, filename, original_name, mime_type, size_bytes, created_at
|
||||||
FROM journal_images WHERE entry_id = ANY($1) ORDER BY created_at`,
|
FROM journal_images WHERE entry_id = ANY($1) ORDER BY created_at`,
|
||||||
@@ -101,7 +154,6 @@ func (s *JournalStore) ListByDate(ctx context.Context, userID, date string) ([]d
|
|||||||
if err := imgRows.Err(); err != nil {
|
if err := imgRows.Err(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, e := range entries {
|
for i, e := range entries {
|
||||||
entries[i].Images = imgMap[e.EntryID]
|
entries[i].Images = imgMap[e.EntryID]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
user_id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
user_id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||||
username TEXT NOT NULL UNIQUE,
|
username TEXT NOT NULL UNIQUE,
|
||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
|
is_admin BOOLEAN NOT NULL DEFAULT false,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -78,9 +79,12 @@ CREATE TABLE IF NOT EXISTS journal_entries (
|
|||||||
description TEXT NOT NULL DEFAULT '',
|
description TEXT NOT NULL DEFAULT '',
|
||||||
lat DOUBLE PRECISION,
|
lat DOUBLE PRECISION,
|
||||||
lon DOUBLE PRECISION,
|
lon DOUBLE PRECISION,
|
||||||
|
visibility TEXT NOT NULL DEFAULT 'private',
|
||||||
|
hashtags TEXT[] NOT NULL DEFAULT '{}',
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS journal_entries_user_date_idx ON journal_entries(user_id, entry_date);
|
CREATE INDEX IF NOT EXISTS journal_entries_user_date_idx ON journal_entries(user_id, entry_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS journal_entries_public_idx ON journal_entries(visibility, created_at DESC);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS journal_images (
|
CREATE TABLE IF NOT EXISTS journal_images (
|
||||||
image_id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
image_id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||||
|
|||||||
43
backend/internal/db/users.go
Normal file
43
backend/internal/db/users.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"github.com/jacek/pamietnik/backend/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserStore struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUserStore(pool *pgxpool.Pool) *UserStore {
|
||||||
|
return &UserStore{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUsers returns all users ordered by created_at.
|
||||||
|
func (s *UserStore) ListUsers(ctx context.Context) ([]domain.User, error) {
|
||||||
|
rows, err := s.pool.Query(ctx,
|
||||||
|
`SELECT user_id, username, is_admin, created_at FROM users ORDER BY created_at`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var users []domain.User
|
||||||
|
for rows.Next() {
|
||||||
|
var u domain.User
|
||||||
|
if err := rows.Scan(&u.UserID, &u.Username, &u.IsAdmin, &u.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
users = append(users, u)
|
||||||
|
}
|
||||||
|
return users, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUser removes a user by ID.
|
||||||
|
func (s *UserStore) DeleteUser(ctx context.Context, userID string) error {
|
||||||
|
_, err := s.pool.Exec(ctx, `DELETE FROM users WHERE user_id = $1`, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -56,6 +56,8 @@ type JournalEntry struct {
|
|||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Lat *float64 `json:"lat,omitempty"`
|
Lat *float64 `json:"lat,omitempty"`
|
||||||
Lon *float64 `json:"lon,omitempty"`
|
Lon *float64 `json:"lon,omitempty"`
|
||||||
|
Visibility string `json:"visibility"` // "public" | "private"
|
||||||
|
Hashtags []string `json:"hashtags"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
Images []JournalImage `json:"images,omitempty"`
|
Images []JournalImage `json:"images,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -74,6 +76,7 @@ type User struct {
|
|||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
PasswordHash string `json:"-"`
|
PasswordHash string `json:"-"`
|
||||||
|
IsAdmin bool `json:"is_admin"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -178,16 +178,17 @@ backend/
|
|||||||
│ ├── trackpoints.go UpsertBatch, ListByDate, ListDays, EnsureDevice
|
│ ├── trackpoints.go UpsertBatch, ListByDate, ListDays, EnsureDevice
|
||||||
│ ├── stops.go ListByDate
|
│ ├── stops.go ListByDate
|
||||||
│ ├── suggestions.go ListByDate
|
│ ├── suggestions.go ListByDate
|
||||||
│ └── journal.go CRUD Journal Entries + Images
|
│ ├── journal.go CRUD Journal Entries + Images; ListPublic, ListByUser
|
||||||
|
│ └── users.go ListUsers, DeleteUser
|
||||||
├── auth/
|
├── auth/
|
||||||
│ └── auth.go HashPassword, VerifyPassword, Login, GetSession, Logout
|
│ └── auth.go HashPassword, VerifyPassword, Login, Register, GetSession, Logout
|
||||||
└── api/
|
└── api/
|
||||||
├── router.go chi Routing, Middleware-Gruppen
|
├── router.go chi Routing, Middleware-Gruppen
|
||||||
├── middleware.go RequireAuth (Session Cookie → Context)
|
├── middleware.go RequireAuth, requireAdmin (Session Cookie → Context)
|
||||||
├── ingest.go HandleSingleTrackpoint, HandleBatchTrackpoints
|
├── ingest.go HandleSingleTrackpoint, HandleBatchTrackpoints
|
||||||
├── query.go HandleListDays, HandleListTrackpoints, Stops, Suggestions
|
├── query.go HandleListDays, HandleListTrackpoints, Stops, Suggestions
|
||||||
├── webui.go Server-side rendered Web UI (Go Templates)
|
├── webui.go Web UI: Feed, Register, Days, Admin-Handlers
|
||||||
├── journal.go Journal Entry Endpoints
|
├── journal.go Journal Entry Endpoints (inkl. visibility + hashtags)
|
||||||
└── response.go writeJSON, writeError helpers
|
└── response.go writeJSON, writeError helpers
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -365,6 +366,101 @@ Schema wird beim API-Start automatisch initialisiert (keine separate Migration n
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 7b. Datenbankschema
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
erDiagram
|
||||||
|
users {
|
||||||
|
TEXT user_id PK
|
||||||
|
TEXT username UK
|
||||||
|
TEXT password_hash
|
||||||
|
BOOLEAN is_admin
|
||||||
|
TIMESTAMPTZ created_at
|
||||||
|
}
|
||||||
|
sessions {
|
||||||
|
TEXT session_id PK
|
||||||
|
TEXT user_id FK
|
||||||
|
TIMESTAMPTZ created_at
|
||||||
|
TIMESTAMPTZ expires_at
|
||||||
|
}
|
||||||
|
devices {
|
||||||
|
TEXT device_id PK
|
||||||
|
TEXT user_id FK
|
||||||
|
TIMESTAMPTZ created_at
|
||||||
|
}
|
||||||
|
trackpoints {
|
||||||
|
BIGSERIAL id PK
|
||||||
|
TEXT event_id
|
||||||
|
TEXT device_id FK
|
||||||
|
TEXT trip_id
|
||||||
|
TIMESTAMPTZ ts
|
||||||
|
DOUBLE lat
|
||||||
|
DOUBLE lon
|
||||||
|
TEXT source
|
||||||
|
TEXT note
|
||||||
|
}
|
||||||
|
stops {
|
||||||
|
TEXT stop_id PK
|
||||||
|
TEXT device_id FK
|
||||||
|
TEXT trip_id
|
||||||
|
TIMESTAMPTZ start_ts
|
||||||
|
TIMESTAMPTZ end_ts
|
||||||
|
DOUBLE center_lat
|
||||||
|
DOUBLE center_lon
|
||||||
|
INT duration_s
|
||||||
|
TEXT place_label
|
||||||
|
}
|
||||||
|
suggestions {
|
||||||
|
TEXT suggestion_id PK
|
||||||
|
TEXT stop_id FK
|
||||||
|
TEXT type
|
||||||
|
TEXT title
|
||||||
|
TEXT text
|
||||||
|
TIMESTAMPTZ created_at
|
||||||
|
TIMESTAMPTZ dismissed_at
|
||||||
|
}
|
||||||
|
journal_entries {
|
||||||
|
TEXT entry_id PK
|
||||||
|
TEXT user_id FK
|
||||||
|
DATE entry_date
|
||||||
|
TIME entry_time
|
||||||
|
TEXT title
|
||||||
|
TEXT description
|
||||||
|
DOUBLE lat
|
||||||
|
DOUBLE lon
|
||||||
|
TEXT visibility
|
||||||
|
TEXT[] hashtags
|
||||||
|
TIMESTAMPTZ created_at
|
||||||
|
}
|
||||||
|
journal_images {
|
||||||
|
TEXT image_id PK
|
||||||
|
TEXT entry_id FK
|
||||||
|
TEXT filename
|
||||||
|
TEXT original_name
|
||||||
|
TEXT mime_type
|
||||||
|
BIGINT size_bytes
|
||||||
|
TIMESTAMPTZ created_at
|
||||||
|
}
|
||||||
|
|
||||||
|
users ||--o{ sessions : has
|
||||||
|
users ||--o{ devices : owns
|
||||||
|
users ||--o{ journal_entries : writes
|
||||||
|
devices ||--o{ trackpoints : records
|
||||||
|
stops ||--o{ suggestions : generates
|
||||||
|
journal_entries ||--o{ journal_images : contains
|
||||||
|
```
|
||||||
|
|
||||||
|
**Wichtige Felder:**
|
||||||
|
|
||||||
|
| Tabelle | Feld | Bedeutung |
|
||||||
|
|---------|------|-----------|
|
||||||
|
| `users` | `is_admin` | Admin-Flag für Zugang zum Admin-Bereich |
|
||||||
|
| `journal_entries` | `visibility` | `public` = im öffentlichen Feed sichtbar; `private` = nur für Autor |
|
||||||
|
| `journal_entries` | `hashtags` | Kommagetrennte Tags als `TEXT[]`-Array |
|
||||||
|
| `trackpoints` | `(device_id, event_id)` | UNIQUE-Constraint für Idempotenz |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 8. Querschnittskonzepte
|
## 8. Querschnittskonzepte
|
||||||
|
|
||||||
### Authentifizierung & Sessions (REQ-AUTH-01, REQ-AUTH-02, DEC-AUTH-01)
|
### Authentifizierung & Sessions (REQ-AUTH-01, REQ-AUTH-02, DEC-AUTH-01)
|
||||||
|
|||||||
Reference in New Issue
Block a user