From b90ff252d1bb9ec70394c95b416645e56569ccd0 Mon Sep 17 00:00:00 2001 From: Matthias Giesselmann Date: Fri, 31 Jul 2026 21:33:42 +0200 Subject: [PATCH] Add Logbuch: project update blog with admin, media and API Public archive with sidebar navigation, project pages, month archive, search and entry pages with image blocks. Admin area for brands, projects, post types, users, api clients, media and the entry editor with drag and drop images, preview per audience, scheduling and publish checks. Read and write API with bearer tokens, audience scoping, idempotent creation, OpenAPI document and editorial guide. Magic link login with configurable allowed domains, whole app behind the session gate. 456 tests including design rule checks. --- .env.example | 11 + .gitignore | 7 + docker-compose.yml | 19 + docs/api.md | 188 + docs/design/direction.md | 162 + docs/design/mockup-overview.html | 546 +++ docs/status.md | 73 + docs/style-guide.md | 79 + .../plans/2026-07-30-logbuch-fundament.md | 2140 +++++++++ .../specs/2026-07-30-logbuch-design.md | 322 ++ drizzle.config.ts | 9 + drizzle/0000_tiny_madrox.sql | 24 + drizzle/0001_serious_echo.sql | 88 + drizzle/0002_glamorous_wild_child.sql | 28 + drizzle/0003_acoustic_sabra.sql | 1 + drizzle/0004_white_groot.sql | 66 + drizzle/0005_eager_magdalene.sql | 15 + drizzle/0006_stiff_living_tribunal.sql | 5 + drizzle/0007_parallel_doctor_doom.sql | 6 + drizzle/0008_true_spot.sql | 38 + drizzle/0009_clean_mac_gargan.sql | 2 + drizzle/meta/0000_snapshot.json | 174 + drizzle/meta/0001_snapshot.json | 837 ++++ drizzle/meta/0002_snapshot.json | 1051 +++++ drizzle/meta/0003_snapshot.json | 1057 +++++ drizzle/meta/0004_snapshot.json | 1519 +++++++ drizzle/meta/0005_snapshot.json | 1636 +++++++ drizzle/meta/0006_snapshot.json | 1689 +++++++ drizzle/meta/0007_snapshot.json | 1729 +++++++ drizzle/meta/0008_snapshot.json | 1918 ++++++++ drizzle/meta/0009_snapshot.json | 1899 ++++++++ drizzle/meta/_journal.json | 76 + messages/de.json | 726 +++ messages/en.json | 726 +++ next.config.ts | 22 + package.json | 54 + pnpm-lock.yaml | 4033 +++++++++++++++++ postcss.config.mjs | 3 + scripts/create-admin.ts | 76 + scripts/publish-due.ts | 25 + scripts/sample-images.ts | 307 ++ scripts/seed.ts | 163 + .../(site)/[project]/[entry]/[month]/page.tsx | 88 + src/app/(site)/[project]/[entry]/page.tsx | 99 + src/app/(site)/[project]/page.tsx | 76 + src/app/(site)/layout.tsx | 26 + src/app/(site)/page.tsx | 130 + src/app/(site)/search/page.tsx | 125 + src/app/admin/api/page.tsx | 327 ++ src/app/admin/brands/[id]/page.tsx | 45 + src/app/admin/brands/new/page.tsx | 25 + src/app/admin/brands/page.tsx | 104 + src/app/admin/clients/page.tsx | 95 + src/app/admin/entries/[id]/page.tsx | 80 + src/app/admin/entries/new/page.tsx | 85 + src/app/admin/entries/page.tsx | 228 + src/app/admin/layout.tsx | 36 + src/app/admin/media/[project]/page.tsx | 61 + src/app/admin/media/page.tsx | 51 + src/app/admin/no-access/page.tsx | 35 + src/app/admin/page.tsx | 146 + src/app/admin/post-types/[id]/page.tsx | 63 + src/app/admin/post-types/new/page.tsx | 25 + src/app/admin/post-types/page.tsx | 111 + src/app/admin/projects/[id]/page.tsx | 64 + src/app/admin/projects/new/page.tsx | 45 + src/app/admin/projects/page.tsx | 131 + src/app/admin/users/[id]/page.tsx | 107 + src/app/admin/users/page.tsx | 86 + src/app/api/auth/[...all]/route.ts | 4 + src/app/api/v1/media/file/[...path]/route.ts | 29 + src/app/api/v1/media/route.ts | 105 + src/app/api/v1/openapi.json/route.ts | 21 + src/app/api/v1/post-types/route.ts | 23 + src/app/api/v1/posts/[slug]/blocks/route.ts | 33 + src/app/api/v1/posts/[slug]/route.ts | 91 + src/app/api/v1/posts/route.ts | 93 + src/app/api/v1/projects/route.ts | 16 + src/app/api/v1/publish-due/route.ts | 32 + src/app/api/v1/read/route.ts | 36 + src/app/api/v1/style-guide/route.ts | 22 + src/app/api/v1/unread/route.ts | 33 + src/app/globals.css | 138 + src/app/layout.tsx | 31 + src/app/login/page.tsx | 76 + src/app/not-found.tsx | 53 + src/components/admin/AdminColorField.tsx | 43 + src/components/admin/AdminField.tsx | 28 + src/components/admin/AdminFormMessage.tsx | 26 + src/components/admin/AdminHeading.tsx | 25 + src/components/admin/AdminNotice.tsx | 51 + src/components/admin/AdminSubmit.tsx | 32 + src/components/admin/ApiEndpoint.tsx | 33 + src/components/admin/BrandForm.tsx | 121 + src/components/admin/ClientForm.tsx | 149 + src/components/admin/ClientRevokeButton.tsx | 33 + src/components/admin/EntryBlockCard.tsx | 350 ++ src/components/admin/EntryEditor.tsx | 814 ++++ src/components/admin/EntryFilters.tsx | 103 + src/components/admin/EntryMediaPicker.tsx | 153 + src/components/admin/EntryPreview.tsx | 274 ++ src/components/admin/EntryStatusChip.tsx | 24 + src/components/admin/LoginForm.tsx | 81 + src/components/admin/MagicLinkForm.tsx | 80 + src/components/admin/MediaCard.tsx | 64 + src/components/admin/MediaMessage.tsx | 24 + src/components/admin/MediaMetaForm.tsx | 53 + src/components/admin/MediaUploadForm.tsx | 59 + src/components/admin/PostTypeDeleteButton.tsx | 38 + src/components/admin/PostTypeForm.tsx | 156 + src/components/admin/ProjectForm.tsx | 196 + src/components/admin/SignOutButton.tsx | 19 + src/components/admin/UserAdminToggle.tsx | 46 + src/components/admin/UserRoleRow.tsx | 56 + src/components/admin/styles.ts | 29 + src/components/archive/ArchiveMonths.tsx | 81 + src/components/archive/EmptyNotice.tsx | 27 + src/components/archive/EntryList.tsx | 30 + src/components/archive/EntryRow.tsx | 69 + src/components/archive/FeatureEntry.tsx | 91 + src/components/archive/FilterBar.tsx | 50 + src/components/archive/Masthead.tsx | 19 + src/components/archive/Pagination.tsx | 58 + src/components/archive/PostDetail.tsx | 151 + src/components/archive/ProjectArchive.tsx | 178 + src/components/archive/SideEntries.tsx | 78 + src/components/archive/TypeFilter.tsx | 72 + src/components/archive/ViewHeader.tsx | 41 + src/components/blocks/BeforeAfterBlock.tsx | 32 + src/components/blocks/BeforeAfterSlider.tsx | 76 + src/components/blocks/BlockRenderer.tsx | 26 + src/components/blocks/CalloutBlock.tsx | 30 + src/components/blocks/CodeBlock.tsx | 27 + src/components/blocks/GalleryBlock.tsx | 22 + src/components/blocks/GalleryFrames.tsx | 205 + src/components/blocks/ImageBlock.tsx | 13 + src/components/blocks/LinkBlock.tsx | 29 + src/components/blocks/MediaFrame.tsx | 47 + src/components/blocks/QuoteBlock.tsx | 18 + src/components/blocks/TextBlock.tsx | 22 + src/components/blocks/VideoBlock.tsx | 49 + src/components/blocks/data.ts | 35 + src/components/blocks/registry.ts | 27 + src/components/blocks/types.ts | 7 + src/components/layout/AdminNav.tsx | 108 + src/components/layout/AppShell.tsx | 169 + src/components/layout/NavLink.tsx | 59 + src/components/layout/SiteFooter.tsx | 27 + src/components/layout/SiteNav.tsx | 113 + src/components/layout/Wordmark.tsx | 33 + src/components/layout/Wrap.tsx | 10 + src/components/ui/AuthorMark.tsx | 29 + src/components/ui/Badge.tsx | 26 + src/components/ui/Chip.tsx | 36 + src/components/ui/Cover.tsx | 67 + src/components/ui/EntryDate.tsx | 31 + src/components/ui/FieldRow.tsx | 19 + src/components/ui/Highlight.tsx | 42 + src/components/ui/Plate.tsx | 59 + src/components/ui/Scroller.tsx | 39 + src/components/ui/SectionLabel.tsx | 20 + src/components/ui/Stamp.tsx | 20 + src/components/ui/ThemeToggle.tsx | 66 + src/components/ui/Watermark.tsx | 17 + src/components/ui/theme.ts | 51 + src/data/db.ts | 14 + src/data/repositories/archive.ts | 515 +++ src/data/repositories/audit.ts | 40 + src/data/repositories/clients.ts | 49 + src/data/repositories/entries.ts | 329 ++ src/data/repositories/idempotency.ts | 23 + src/data/repositories/media.ts | 53 + src/data/repositories/post-types.ts | 72 + src/data/repositories/posts.ts | 167 + src/data/repositories/projects.ts | 91 + src/data/repositories/reads.ts | 94 + src/data/repositories/user-roles.ts | 46 + src/data/repositories/users.ts | 33 + src/data/schema/audit.ts | 20 + src/data/schema/auth.ts | 67 + src/data/schema/brands.ts | 26 + src/data/schema/clients.ts | 31 + src/data/schema/counters.ts | 9 + src/data/schema/idempotency.ts | 12 + src/data/schema/index.ts | 9 + src/data/schema/media.ts | 32 + src/data/schema/post-types.ts | 14 + src/data/schema/posts.ts | 74 + src/domain/access-paths.ts | 25 + src/domain/audience.ts | 19 + src/domain/blocks.ts | 150 + src/domain/color.ts | 18 + src/domain/email-domains.ts | 31 + src/domain/magic-link.ts | 68 + src/domain/post-status.ts | 19 + src/domain/post-type.ts | 34 + src/domain/project-code.ts | 72 + src/domain/publish-checks.ts | 47 + src/domain/slug.ts | 33 + src/domain/types.ts | 10 + src/i18n/config.ts | 11 + src/i18n/request.ts | 14 + src/lib/admin-actions.ts | 140 + src/lib/admin-brands.ts | 110 + src/lib/admin-clients.ts | 114 + src/lib/admin-forms.ts | 69 + src/lib/admin-post-types.ts | 156 + src/lib/admin-projects.ts | 139 + src/lib/admin-routes.ts | 33 + src/lib/admin-users.ts | 116 + src/lib/api-auth.ts | 23 + src/lib/api-entries.ts | 391 ++ src/lib/api-problem.ts | 38 + src/lib/auth-access.ts | 60 + src/lib/auth-actions.ts | 100 + src/lib/auth-guards.ts | 64 + src/lib/auth-routes.ts | 34 + src/lib/auth-types.ts | 8 + src/lib/auth.ts | 72 + src/lib/cover.ts | 19 + src/lib/dates.ts | 25 + src/lib/entries.ts | 358 ++ src/lib/entry-actions.ts | 200 + src/lib/entry-media.ts | 21 + src/lib/entry-types.ts | 91 + src/lib/fonts.ts | 30 + src/lib/login-domains.ts | 13 + src/lib/magic-link-attempts.ts | 30 + src/lib/magic-link-mail.ts | 19 + src/lib/mailer.ts | 44 + src/lib/media-actions.ts | 82 + src/lib/media-images.ts | 100 + src/lib/media-upload.ts | 164 + src/lib/media-url.ts | 56 + src/lib/openapi.ts | 337 ++ src/lib/post-content.ts | 64 + src/lib/post-types.ts | 16 + src/lib/problem.ts | 6 + src/lib/project-view.ts | 24 + src/lib/publish-due.ts | 23 + src/lib/query.ts | 65 + src/lib/request-path.ts | 8 + src/lib/routes.ts | 73 + src/lib/storage.ts | 159 + src/middleware.ts | 25 + tests/api/media.test.ts | 235 + tests/api/posts.test.ts | 284 ++ tests/api/projects.test.ts | 52 + tests/api/reads.test.ts | 224 + tests/api/write.test.ts | 304 ++ tests/components/blocks.test.tsx | 127 + tests/data/archive.test.ts | 373 ++ tests/data/entries.test.ts | 156 + tests/data/posts.test.ts | 188 + tests/data/projects.test.ts | 61 + tests/design/rules.test.ts | 159 + tests/domain/access-paths.test.ts | 48 + tests/domain/audience.test.ts | 57 + tests/domain/blocks.test.ts | 74 + tests/domain/color.test.ts | 23 + tests/domain/email-domains.test.ts | 65 + tests/domain/magic-link.test.ts | 142 + tests/domain/post-status.test.ts | 49 + tests/domain/project-code.test.ts | 127 + tests/domain/publish-checks.test.ts | 71 + tests/domain/slug.test.ts | 39 + tests/lib/admin-brands.test.ts | 68 + tests/lib/admin-clients.test.ts | 159 + tests/lib/admin-projects.test.ts | 236 + tests/lib/admin-users.test.ts | 133 + tests/lib/api-auth.test.ts | 49 + tests/lib/auth-access.test.ts | 144 + tests/lib/auth-routes.test.ts | 35 + tests/lib/entries.test.ts | 367 ++ tests/lib/login-domains.test.ts | 16 + tests/lib/magic-link-attempts.test.ts | 38 + tests/lib/media-images.test.ts | 81 + tests/lib/media-upload.test.ts | 59 + tests/lib/post-content.test.ts | 67 + tests/lib/publish-due.test.ts | 58 + tests/lib/query.test.ts | 49 + tests/lib/routes.test.ts | 23 + tests/lib/storage.test.ts | 126 + tests/scripts/seed.test.ts | 95 + tests/setup.ts | 18 + tests/smoke.test.ts | 7 + tests/support/admin.ts | 60 + tests/support/entries.ts | 49 + tests/support/post-types.ts | 38 + tsconfig.json | 45 + vitest.config.ts | 13 + 291 files changed, 43671 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 docker-compose.yml create mode 100644 docs/api.md create mode 100644 docs/design/direction.md create mode 100644 docs/design/mockup-overview.html create mode 100644 docs/status.md create mode 100644 docs/style-guide.md create mode 100644 docs/superpowers/plans/2026-07-30-logbuch-fundament.md create mode 100644 docs/superpowers/specs/2026-07-30-logbuch-design.md create mode 100644 drizzle.config.ts create mode 100644 drizzle/0000_tiny_madrox.sql create mode 100644 drizzle/0001_serious_echo.sql create mode 100644 drizzle/0002_glamorous_wild_child.sql create mode 100644 drizzle/0003_acoustic_sabra.sql create mode 100644 drizzle/0004_white_groot.sql create mode 100644 drizzle/0005_eager_magdalene.sql create mode 100644 drizzle/0006_stiff_living_tribunal.sql create mode 100644 drizzle/0007_parallel_doctor_doom.sql create mode 100644 drizzle/0008_true_spot.sql create mode 100644 drizzle/0009_clean_mac_gargan.sql create mode 100644 drizzle/meta/0000_snapshot.json create mode 100644 drizzle/meta/0001_snapshot.json create mode 100644 drizzle/meta/0002_snapshot.json create mode 100644 drizzle/meta/0003_snapshot.json create mode 100644 drizzle/meta/0004_snapshot.json create mode 100644 drizzle/meta/0005_snapshot.json create mode 100644 drizzle/meta/0006_snapshot.json create mode 100644 drizzle/meta/0007_snapshot.json create mode 100644 drizzle/meta/0008_snapshot.json create mode 100644 drizzle/meta/0009_snapshot.json create mode 100644 drizzle/meta/_journal.json create mode 100644 messages/de.json create mode 100644 messages/en.json create mode 100644 next.config.ts create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 postcss.config.mjs create mode 100644 scripts/create-admin.ts create mode 100644 scripts/publish-due.ts create mode 100644 scripts/sample-images.ts create mode 100644 scripts/seed.ts create mode 100644 src/app/(site)/[project]/[entry]/[month]/page.tsx create mode 100644 src/app/(site)/[project]/[entry]/page.tsx create mode 100644 src/app/(site)/[project]/page.tsx create mode 100644 src/app/(site)/layout.tsx create mode 100644 src/app/(site)/page.tsx create mode 100644 src/app/(site)/search/page.tsx create mode 100644 src/app/admin/api/page.tsx create mode 100644 src/app/admin/brands/[id]/page.tsx create mode 100644 src/app/admin/brands/new/page.tsx create mode 100644 src/app/admin/brands/page.tsx create mode 100644 src/app/admin/clients/page.tsx create mode 100644 src/app/admin/entries/[id]/page.tsx create mode 100644 src/app/admin/entries/new/page.tsx create mode 100644 src/app/admin/entries/page.tsx create mode 100644 src/app/admin/layout.tsx create mode 100644 src/app/admin/media/[project]/page.tsx create mode 100644 src/app/admin/media/page.tsx create mode 100644 src/app/admin/no-access/page.tsx create mode 100644 src/app/admin/page.tsx create mode 100644 src/app/admin/post-types/[id]/page.tsx create mode 100644 src/app/admin/post-types/new/page.tsx create mode 100644 src/app/admin/post-types/page.tsx create mode 100644 src/app/admin/projects/[id]/page.tsx create mode 100644 src/app/admin/projects/new/page.tsx create mode 100644 src/app/admin/projects/page.tsx create mode 100644 src/app/admin/users/[id]/page.tsx create mode 100644 src/app/admin/users/page.tsx create mode 100644 src/app/api/auth/[...all]/route.ts create mode 100644 src/app/api/v1/media/file/[...path]/route.ts create mode 100644 src/app/api/v1/media/route.ts create mode 100644 src/app/api/v1/openapi.json/route.ts create mode 100644 src/app/api/v1/post-types/route.ts create mode 100644 src/app/api/v1/posts/[slug]/blocks/route.ts create mode 100644 src/app/api/v1/posts/[slug]/route.ts create mode 100644 src/app/api/v1/posts/route.ts create mode 100644 src/app/api/v1/projects/route.ts create mode 100644 src/app/api/v1/publish-due/route.ts create mode 100644 src/app/api/v1/read/route.ts create mode 100644 src/app/api/v1/style-guide/route.ts create mode 100644 src/app/api/v1/unread/route.ts create mode 100644 src/app/globals.css create mode 100644 src/app/layout.tsx create mode 100644 src/app/login/page.tsx create mode 100644 src/app/not-found.tsx create mode 100644 src/components/admin/AdminColorField.tsx create mode 100644 src/components/admin/AdminField.tsx create mode 100644 src/components/admin/AdminFormMessage.tsx create mode 100644 src/components/admin/AdminHeading.tsx create mode 100644 src/components/admin/AdminNotice.tsx create mode 100644 src/components/admin/AdminSubmit.tsx create mode 100644 src/components/admin/ApiEndpoint.tsx create mode 100644 src/components/admin/BrandForm.tsx create mode 100644 src/components/admin/ClientForm.tsx create mode 100644 src/components/admin/ClientRevokeButton.tsx create mode 100644 src/components/admin/EntryBlockCard.tsx create mode 100644 src/components/admin/EntryEditor.tsx create mode 100644 src/components/admin/EntryFilters.tsx create mode 100644 src/components/admin/EntryMediaPicker.tsx create mode 100644 src/components/admin/EntryPreview.tsx create mode 100644 src/components/admin/EntryStatusChip.tsx create mode 100644 src/components/admin/LoginForm.tsx create mode 100644 src/components/admin/MagicLinkForm.tsx create mode 100644 src/components/admin/MediaCard.tsx create mode 100644 src/components/admin/MediaMessage.tsx create mode 100644 src/components/admin/MediaMetaForm.tsx create mode 100644 src/components/admin/MediaUploadForm.tsx create mode 100644 src/components/admin/PostTypeDeleteButton.tsx create mode 100644 src/components/admin/PostTypeForm.tsx create mode 100644 src/components/admin/ProjectForm.tsx create mode 100644 src/components/admin/SignOutButton.tsx create mode 100644 src/components/admin/UserAdminToggle.tsx create mode 100644 src/components/admin/UserRoleRow.tsx create mode 100644 src/components/admin/styles.ts create mode 100644 src/components/archive/ArchiveMonths.tsx create mode 100644 src/components/archive/EmptyNotice.tsx create mode 100644 src/components/archive/EntryList.tsx create mode 100644 src/components/archive/EntryRow.tsx create mode 100644 src/components/archive/FeatureEntry.tsx create mode 100644 src/components/archive/FilterBar.tsx create mode 100644 src/components/archive/Masthead.tsx create mode 100644 src/components/archive/Pagination.tsx create mode 100644 src/components/archive/PostDetail.tsx create mode 100644 src/components/archive/ProjectArchive.tsx create mode 100644 src/components/archive/SideEntries.tsx create mode 100644 src/components/archive/TypeFilter.tsx create mode 100644 src/components/archive/ViewHeader.tsx create mode 100644 src/components/blocks/BeforeAfterBlock.tsx create mode 100644 src/components/blocks/BeforeAfterSlider.tsx create mode 100644 src/components/blocks/BlockRenderer.tsx create mode 100644 src/components/blocks/CalloutBlock.tsx create mode 100644 src/components/blocks/CodeBlock.tsx create mode 100644 src/components/blocks/GalleryBlock.tsx create mode 100644 src/components/blocks/GalleryFrames.tsx create mode 100644 src/components/blocks/ImageBlock.tsx create mode 100644 src/components/blocks/LinkBlock.tsx create mode 100644 src/components/blocks/MediaFrame.tsx create mode 100644 src/components/blocks/QuoteBlock.tsx create mode 100644 src/components/blocks/TextBlock.tsx create mode 100644 src/components/blocks/VideoBlock.tsx create mode 100644 src/components/blocks/data.ts create mode 100644 src/components/blocks/registry.ts create mode 100644 src/components/blocks/types.ts create mode 100644 src/components/layout/AdminNav.tsx create mode 100644 src/components/layout/AppShell.tsx create mode 100644 src/components/layout/NavLink.tsx create mode 100644 src/components/layout/SiteFooter.tsx create mode 100644 src/components/layout/SiteNav.tsx create mode 100644 src/components/layout/Wordmark.tsx create mode 100644 src/components/layout/Wrap.tsx create mode 100644 src/components/ui/AuthorMark.tsx create mode 100644 src/components/ui/Badge.tsx create mode 100644 src/components/ui/Chip.tsx create mode 100644 src/components/ui/Cover.tsx create mode 100644 src/components/ui/EntryDate.tsx create mode 100644 src/components/ui/FieldRow.tsx create mode 100644 src/components/ui/Highlight.tsx create mode 100644 src/components/ui/Plate.tsx create mode 100644 src/components/ui/Scroller.tsx create mode 100644 src/components/ui/SectionLabel.tsx create mode 100644 src/components/ui/Stamp.tsx create mode 100644 src/components/ui/ThemeToggle.tsx create mode 100644 src/components/ui/Watermark.tsx create mode 100644 src/components/ui/theme.ts create mode 100644 src/data/db.ts create mode 100644 src/data/repositories/archive.ts create mode 100644 src/data/repositories/audit.ts create mode 100644 src/data/repositories/clients.ts create mode 100644 src/data/repositories/entries.ts create mode 100644 src/data/repositories/idempotency.ts create mode 100644 src/data/repositories/media.ts create mode 100644 src/data/repositories/post-types.ts create mode 100644 src/data/repositories/posts.ts create mode 100644 src/data/repositories/projects.ts create mode 100644 src/data/repositories/reads.ts create mode 100644 src/data/repositories/user-roles.ts create mode 100644 src/data/repositories/users.ts create mode 100644 src/data/schema/audit.ts create mode 100644 src/data/schema/auth.ts create mode 100644 src/data/schema/brands.ts create mode 100644 src/data/schema/clients.ts create mode 100644 src/data/schema/counters.ts create mode 100644 src/data/schema/idempotency.ts create mode 100644 src/data/schema/index.ts create mode 100644 src/data/schema/media.ts create mode 100644 src/data/schema/post-types.ts create mode 100644 src/data/schema/posts.ts create mode 100644 src/domain/access-paths.ts create mode 100644 src/domain/audience.ts create mode 100644 src/domain/blocks.ts create mode 100644 src/domain/color.ts create mode 100644 src/domain/email-domains.ts create mode 100644 src/domain/magic-link.ts create mode 100644 src/domain/post-status.ts create mode 100644 src/domain/post-type.ts create mode 100644 src/domain/project-code.ts create mode 100644 src/domain/publish-checks.ts create mode 100644 src/domain/slug.ts create mode 100644 src/domain/types.ts create mode 100644 src/i18n/config.ts create mode 100644 src/i18n/request.ts create mode 100644 src/lib/admin-actions.ts create mode 100644 src/lib/admin-brands.ts create mode 100644 src/lib/admin-clients.ts create mode 100644 src/lib/admin-forms.ts create mode 100644 src/lib/admin-post-types.ts create mode 100644 src/lib/admin-projects.ts create mode 100644 src/lib/admin-routes.ts create mode 100644 src/lib/admin-users.ts create mode 100644 src/lib/api-auth.ts create mode 100644 src/lib/api-entries.ts create mode 100644 src/lib/api-problem.ts create mode 100644 src/lib/auth-access.ts create mode 100644 src/lib/auth-actions.ts create mode 100644 src/lib/auth-guards.ts create mode 100644 src/lib/auth-routes.ts create mode 100644 src/lib/auth-types.ts create mode 100644 src/lib/auth.ts create mode 100644 src/lib/cover.ts create mode 100644 src/lib/dates.ts create mode 100644 src/lib/entries.ts create mode 100644 src/lib/entry-actions.ts create mode 100644 src/lib/entry-media.ts create mode 100644 src/lib/entry-types.ts create mode 100644 src/lib/fonts.ts create mode 100644 src/lib/login-domains.ts create mode 100644 src/lib/magic-link-attempts.ts create mode 100644 src/lib/magic-link-mail.ts create mode 100644 src/lib/mailer.ts create mode 100644 src/lib/media-actions.ts create mode 100644 src/lib/media-images.ts create mode 100644 src/lib/media-upload.ts create mode 100644 src/lib/media-url.ts create mode 100644 src/lib/openapi.ts create mode 100644 src/lib/post-content.ts create mode 100644 src/lib/post-types.ts create mode 100644 src/lib/problem.ts create mode 100644 src/lib/project-view.ts create mode 100644 src/lib/publish-due.ts create mode 100644 src/lib/query.ts create mode 100644 src/lib/request-path.ts create mode 100644 src/lib/routes.ts create mode 100644 src/lib/storage.ts create mode 100644 src/middleware.ts create mode 100644 tests/api/media.test.ts create mode 100644 tests/api/posts.test.ts create mode 100644 tests/api/projects.test.ts create mode 100644 tests/api/reads.test.ts create mode 100644 tests/api/write.test.ts create mode 100644 tests/components/blocks.test.tsx create mode 100644 tests/data/archive.test.ts create mode 100644 tests/data/entries.test.ts create mode 100644 tests/data/posts.test.ts create mode 100644 tests/data/projects.test.ts create mode 100644 tests/design/rules.test.ts create mode 100644 tests/domain/access-paths.test.ts create mode 100644 tests/domain/audience.test.ts create mode 100644 tests/domain/blocks.test.ts create mode 100644 tests/domain/color.test.ts create mode 100644 tests/domain/email-domains.test.ts create mode 100644 tests/domain/magic-link.test.ts create mode 100644 tests/domain/post-status.test.ts create mode 100644 tests/domain/project-code.test.ts create mode 100644 tests/domain/publish-checks.test.ts create mode 100644 tests/domain/slug.test.ts create mode 100644 tests/lib/admin-brands.test.ts create mode 100644 tests/lib/admin-clients.test.ts create mode 100644 tests/lib/admin-projects.test.ts create mode 100644 tests/lib/admin-users.test.ts create mode 100644 tests/lib/api-auth.test.ts create mode 100644 tests/lib/auth-access.test.ts create mode 100644 tests/lib/auth-routes.test.ts create mode 100644 tests/lib/entries.test.ts create mode 100644 tests/lib/login-domains.test.ts create mode 100644 tests/lib/magic-link-attempts.test.ts create mode 100644 tests/lib/media-images.test.ts create mode 100644 tests/lib/media-upload.test.ts create mode 100644 tests/lib/post-content.test.ts create mode 100644 tests/lib/publish-due.test.ts create mode 100644 tests/lib/query.test.ts create mode 100644 tests/lib/routes.test.ts create mode 100644 tests/lib/storage.test.ts create mode 100644 tests/scripts/seed.test.ts create mode 100644 tests/setup.ts create mode 100644 tests/smoke.test.ts create mode 100644 tests/support/admin.ts create mode 100644 tests/support/entries.ts create mode 100644 tests/support/post-types.ts create mode 100644 tsconfig.json create mode 100644 vitest.config.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b8c94b4 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +DATABASE_URL=postgres://logbuch:logbuch@localhost:5442/logbuch +DATABASE_URL_TEST=postgres://logbuch:logbuch@localhost:5442/logbuch_test +BETTER_AUTH_URL=http://localhost:4700 +BETTER_AUTH_SECRET= +ADMIN_EMAIL= +ADMIN_PASSWORD= +STORAGE_DIR=./storage +CRON_SECRET= +LOGIN_ALLOWED_DOMAINS=nyo.de,pocketrocket.de,pocket-rocket.io +SMTP_URL= +MAIL_FROM=logbuch@localhost diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..528c60d --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules +.next +.env +*.tsbuildinfo +next-env.d.ts +.DS_Store +/storage diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b4e4952 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,19 @@ +services: + db: + image: postgres:18 + environment: + POSTGRES_USER: logbuch + POSTGRES_PASSWORD: logbuch + POSTGRES_DB: logbuch + ports: + - "5442:5432" + volumes: + - logbuch-db:/var/lib/postgresql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U logbuch"] + interval: 5s + timeout: 3s + retries: 10 + +volumes: + logbuch-db: diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..7885ac0 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,188 @@ +# Logbuch API + +Stand: 31.07.2026. Diese Datei beschreibt den tatsächlichen Stand. Was noch nicht gebaut ist, steht am Ende unter "Noch nicht vorhanden" und ist dort auch so gekennzeichnet. + +## Wofür Logbuch da ist + +Logbuch hält fest, was in den Produkten der Firmengruppe passiert ist. Alle zwei Wochen entsteht pro Projekt ein Eintrag: eine neue Funktion, eine Verbesserung, ein behobener Fehler, eine Änderung mit Auswirkung. + +Es ist kein Änderungsprotokoll für Entwickler. Es ist für Kollegen und später Kunden, die wissen wollen, was sich geändert hat, ohne Tickets oder Code zu lesen. + +Die Anwendung besteht aus drei Teilen: dem Archiv zum Lesen, dem Adminbereich zum Schreiben, und dieser API. Über die API holen andere Anwendungen ihre Einträge und zeigen sie ihren Nutzern an, und über sie können Einträge verfasst werden. + +## Grundlagen + +Basis-Adresse lokal: `http://localhost:4700` + +Alle Endpunkte liegen unter `/api/v1`. Angemeldet wird sich mit einem Token im Kopf: + +``` +Authorization: Bearer +``` + +Zugänge werden im Adminbereich unter Verwaltung, Zugänge angelegt. Beim Anlegen wird das Token einmal im Klartext gezeigt, danach nie wieder. Gespeichert ist nur ein Hash. + +Ein Zugang hat drei Eigenschaften, die zusammen bestimmen, was er darf: + +| Eigenschaft | Bedeutung | +|---|---| +| Modus | `read` liest, `write` schreibt zusätzlich | +| Zielgruppe | `internal`, `customer` oder `public`. Bestimmt, welche Einträge der Zugang überhaupt sieht | +| Projektbindung | Ist ein Projekt gesetzt, sieht und schreibt der Zugang ausschließlich dort | + +Die Zielgruppe ist die wichtigste Einstellung. Ein Zugang mit `customer` sieht Kundenbeiträge und öffentliche, aber niemals interne. Das gilt serverseitig, kein Parameter kann das umgehen. + +Ein Zugang mit Schreibrecht kann **niemals veröffentlichen**. Alles landet als Entwurf, ein Mensch gibt frei. + +Fehler kommen einheitlich als Problem-JSON: + +```json +{ "type": "unauthorized", "title": "unauthorized", "status": 401, "detail": null } +``` + +## Lesen + +### GET /api/v1/projects + +Die Projekte, die der Zugang sehen darf. + +```bash +curl -s -H "Authorization: Bearer $TOKEN" \ + http://localhost:4700/api/v1/projects +``` + +Antwort: `{ "items": [ { "id", "slug", "name", "code", "color", "description", "sort", "isActive" } ] }` + +### GET /api/v1/posts + +Die veröffentlichten Einträge, neueste zuerst. + +| Parameter | Wirkung | +|---|---| +| `project` | Slug eines Projekts | +| `since` | Zeitpunkt, ab dem gesucht wird | +| `type` | `feature`, `improvement`, `fix`, `breaking`, `info` | +| `tag` | Schlagwort | +| `page` | Seite, ab 1 | +| `per_page` | 1 bis 100, Standard 25 | + +```bash +curl -s -H "Authorization: Bearer $TOKEN" \ + "http://localhost:4700/api/v1/posts?project=trakk&per_page=5" +``` + +Antwort: `{ "items": [...], "meta": { "total", "page", "per_page" } }` + +Ein Eintrag in der Liste trägt: `id`, `slug`, `title`, `teaser`, `type`, `audience`, `publishAt`, `number`, `projectSlug`, `projectName`, `projectCode`, `projectColor`. + +### GET /api/v1/posts/:slug + +Ein einzelner Eintrag. Ist er für die Zielgruppe des Zugangs nicht sichtbar, kommt 404, nicht 403. Damit verrät die Antwort nicht, dass es ihn gibt. + +### GET /api/v1/unread + +Was ein Nutzer der einbindenden Anwendung noch nicht gelesen hat. + +```bash +curl -s -H "Authorization: Bearer $TOKEN" \ + "http://localhost:4700/api/v1/unread?external_user_id=u-42" +``` + +`external_user_id` ist die Nutzerkennung der aufrufenden Anwendung, nicht die eines Logbuch-Kontos. Antwort: `{ "count", "items": [...] }`. + +Der Zugang muss dafür an ein Projekt gebunden sein, sonst 422. + +### POST /api/v1/read + +Markiert Einträge als gelesen. + +```bash +curl -s -X POST -H "Authorization: Bearer $TOKEN" \ + -H "content-type: application/json" \ + -d '{"external_user_id":"u-42","post_ids":[""]}' \ + http://localhost:4700/api/v1/read +``` + +Antwort: `{ "marked": 1 }`, also die Zahl der tatsächlich neu vermerkten Einträge. Unbekannte Kennungen und Einträge fremder Projekte werden still verworfen, die Antwort bleibt 200. + +## Bilder + +### POST /api/v1/media + +Lädt ein Bild hoch, erzeugt WebP-Varianten und legt einen Datensatz an. Braucht einen Zugang mit Schreibrecht. + +```bash +curl -s -X POST -H "Authorization: Bearer $TOKEN" \ + -F "file=@screenshot.png" \ + -F "projectId=" \ + -F "alt=Spaltenmenü mit den neuen Einträgen" \ + http://localhost:4700/api/v1/media +``` + +Grenzen: nur Bildformate, höchstens 15 MB. + +### GET /api/v1/media/file/... + +Liefert eine Variante aus. Die Pfade stehen am Medium unter `variants`, je Eintrag mit `width`, `format` und `path`. + +## Redaktionsleitfaden + +Wer über die API schreibt, schreibt für Menschen. Diese Regeln gelten für jeden Eintrag. + +**Aufbau.** Titel, Anreißer, dann der Text. Der Titel sagt, was sich geändert hat, nicht dass sich etwas geändert hat. Der Anreißer fasst in zwei bis drei Sätzen zusammen, was der Leser davon hat. Der Text erklärt, was vorher war, was jetzt ist, und was zu tun bleibt. + +**Gute Titel:** + +- "SLA-Uhr zählt Feiertage nicht mehr mit" +- "Spalten per Rechtsklick verwalten" + +**Schlechte Titel:** + +- "Verbesserungen an der Tabelle" (sagt nichts) +- "Wir haben ein spannendes neues Feature gebaut" (Werbung, keine Information) + +**Ton.** Sachlich und direkt. Aktive Verben. Keine Werbesprache, keine Superlative, keine Ausrufezeichen, keine Emojis. Keine Gedankenstriche, immer normales Minus. Echte Umlaute. + +**Länge.** So lang wie nötig. Meist drei bis sechs Absätze. Ein Fehlerbehebung darf auch aus zwei Sätzen bestehen. + +**Bilder.** Zeigen, was sich geändert hat, nicht das Firmenlogo. Jedes Bild bekommt einen Alt-Text, der beschreibt was zu sehen ist. Bei öffentlichen Beiträgen ist der Alt-Text Pflicht, sonst lässt sich der Beitrag nicht veröffentlichen. + +**Zielgruppe.** `internal` für alles, was intern bleibt: Umbauten, Zwischenstände, Interna über Kunden. `customer` für alles, was ein Kunde des Produkts wissen soll. `public` nur für das, was auch außerhalb stehen darf. + +**Wer veröffentlicht.** Nicht der Zugang. Ein Mensch schaut drauf und gibt frei. Ein über die API verfasster Beitrag ist immer ein Vorschlag. + +## Noch nicht vorhanden + +Ehrlich benannt, damit niemand daran vorbei entwickelt. Diese Endpunkte werden gerade gebaut: + +| Geplant | Zweck | +|---|---| +| `POST /api/v1/posts` | Beitrag als Entwurf anlegen | +| `PATCH /api/v1/posts/:id` | Entwurf ändern | +| `PUT /api/v1/posts/:id/blocks` | Inhaltsblöcke setzen | +| `GET /api/v1/posts/:id/preview` | Entwurf zurücklesen | +| `DELETE /api/v1/posts/:id` | Entwurf löschen | +| `GET /api/v1/post-types` | verwaltbare Beitragsarten | +| `GET /api/v1/style-guide` | dieser Leitfaden, maschinenlesbar | +| `GET /api/v1/openapi.json` | maschinenlesbare Beschreibung | +| `/admin/api` | diese Dokumentation im Adminbereich | + +Solange sie fehlen, kann ein Zugang mit Schreibrecht ausschließlich Bilder hochladen. + +## Blocktypen + +Der Inhalt eines Beitrags besteht aus Blöcken in fester Reihenfolge. + +| Typ | Daten | +|---|---| +| `text` | `text` | +| `image` | `mediaId` | +| `gallery` | `mediaIds` | +| `before_after` | `beforeMediaId`, `afterMediaId` | +| `video` | `url`, `title` | +| `quote` | `text`, `source` | +| `code` | `code`, `language` | +| `link` | `url`, `title`, `description` | +| `callout` | `text`, `tone` | + +Unbekannte Typen werden bei der Anzeige übersprungen, sie zerlegen die Seite nicht. diff --git a/docs/design/direction.md b/docs/design/direction.md new file mode 100644 index 0000000..b86530a --- /dev/null +++ b/docs/design/direction.md @@ -0,0 +1,162 @@ +# Logbuch Gestaltungsrichtung + +Stand: 30.07.2026. Diese Datei ist verbindlich. Jede Farb-, Schrift- und Abstandsentscheidung wird hieraus abgeleitet, nichts wird zusätzlich erfunden. + +## Warum der erste Entwurf nicht trug + +Der erste Entwurf bestand aus Haarlinien, Mono-Kleinkram und einer sehr großen Überschrift. Er funktionierte nur unter der Annahme, dass jeder Eintrag ein Bild mitbringt. Ohne Bilder blieb eine graue Liste ohne Halt. Die Gestaltung muss vollständig ohne Bildmaterial tragen und mit Bildmaterial besser werden, nicht umgekehrt. + +## Leitgedanke + +Logbuch zeigt Meldungen aus einer Flotte von Projekten. Die wichtigste Information eines Eintrags ist nicht sein Datum, sondern **von welchem Projekt er kommt**. Deshalb trägt die Projektfarbe die Seite. + +## Signaturelement: die Kennplatte + +Ein massives Farbfeld in der Projektfarbe mit dem Projektkürzel und der laufenden Eintragsnummer, gesetzt wie eine Rumpf- oder Containerbeschriftung. + +``` +┌──────────────┐ +│ TRK │ +│ 0142 │ +└──────────────┘ +``` + +- Kürzel aus `project.code`, Rückfall auf die ersten drei Buchstaben des Slugs in Großbuchstaben +- Nummer immer vierstellig mit führenden Nullen +- Schrift: Mono, 600, weite Laufweite beim Kürzel, sehr groß bei der Nummer +- Textfarbe auf der Platte: immer Papierfarbe, nie Tinte +- In der Liste als schmale Variante, im Aufmacher als große Fläche + +Die Platte ist der einzige Ort, an dem kräftige Farbe vorkommt. Alles andere bleibt ruhig. + +## Farbe + +Grundpalette, hell: + +| Token | Wert | Verwendung | +|---|---|---| +| `paper` | `#f2f1ec` | Seitenhintergrund | +| `surface` | `#fbfaf6` | abgesetzte Flächen | +| `ink` | `#14161a` | Titel, Fließtext | +| `ink-2` | `#4c5157` | Anreißer, Sekundärtext | +| `ink-3` | `#8a8f95` | Beschriftungen, Datum | +| `rule` | `#dedcd3` | Trennlinien | +| `signal` | `#c43a22` | ausschließlich für Neu-Markierung und die Randlinie | + +Dunkel, dieselben Tokennamen: + +| Token | Wert | +|---|---| +| `paper` | `#121417` | +| `surface` | `#191c20` | +| `ink` | `#edebe4` | +| `ink-2` | `#a9aeb4` | +| `ink-3` | `#757a80` | +| `rule` | `#2a2e34` | +| `signal` | `#e4603f` | + +Projektfarben kommen aus der Datenbank, `project.color`. Sie werden nur auf Kennplatten und als Balken verwendet, niemals als Textfarbe auf Papier. Im dunklen Modus wird die Plattenfarbe über `color-mix` um 12 Prozent zur Papierfarbe abgedunkelt, damit sie nicht leuchtet. + +Verboten: Farbverläufe, Schlagschatten außer einem einzigen für den Aufmacher, farbige Titel. Titel sind immer `ink`, auch wenn sie Verweise sind. Verweisblau gilt nur für echte Verweise im Fließtext. + +## Schrift + +Selbst gehostet über `next/font/google`, keine Systemschriften mehr, damit es auf jedem Rechner gleich aussieht. + +| Rolle | Schrift | Einsatz | +|---|---|---| +| Display | Archivo, 600 und 700, Laufweite -0.02em | Titel, Wortmarke, Abschnittsköpfe | +| Fließtext | Source Serif 4, 400, Zeilenhöhe 1.6 | Anreißer, Artikeltext | +| Technisch | JetBrains Mono, 500 | Kennplatten, Datum, Nummern, Beschriftungen | + +Skala in rem, keine Zwischenwerte erfinden: + +| Stufe | Größe | Verwendung | +|---|---|---| +| micro | 0.6875 | Beschriftungen in Versalien | +| small | 0.8125 | Datum, Metazeilen | +| base | 1.0625 | Fließtext | +| lead | 1.25 | Anreißer im Aufmacher, Titel in der Liste | +| title | 1.75 | Abschnittsköpfe, Titel auf Projektseiten | +| hero | clamp(2.25, 4vw, 3.25) | Aufmachertitel, Seitentitel | +| plate | clamp(2, 5vw, 3.5) | Nummer auf der großen Kennplatte | + +## Nachtrag 30.07.2026: Aufbau nach dem Vergleich + +Ein zweiter Entwurf hat drei Dinge besser gelöst. Sie sind ab sofort verbindlich und stechen die Skizze weiter unten. + +**Feste Seitenleiste statt Filterzeile.** Links eine schmale Spalte über die volle Höhe: Wortmarke mit dem Stand des Logbuchs, Suche, die Einträge Übersicht und Archiv, dann alle Projekte mit Farbpunkt, gruppiert nach Marke. Unten der Hell-Dunkel-Umschalter. Die Projekte sind Daten, die Leiste wächst mit ihnen. Ab Tablettbreite klappt sie zu einer Kopfzeile mit Schublade zusammen. + +**Der Aufmacher trägt ein Bild.** Volle Breite des Inhaltsbereichs, darunter Projektzeile, Titel, Anreißer, Autor. Die Eintragsnummer steht sehr groß und sehr blass hinter dem Text, als Wasserzeichen. Ohne Bild übernimmt die Kennplattenfläche denselben Platz, das Wasserzeichen bleibt. + +**Zweite Spalte rechts.** Neben dem Aufmacher stehen die zwei bis drei nächsten Einträge als kleine Karten mit Vorschaubild, Kürzel, Art, Titel und Anreißerbeginn. + +**Zeilen zeigen mehr.** Eine Listenzeile trägt Kürzel und Nummer, die Art, den Titel, den Anreißer in einer Zeile, rechts Datum und Autor. Nicht nur Titel und Datum. + +**Kürzel-Schreibweise.** Kürzel und Nummer werden als `TRK-0142` gesetzt, mit Bindestrich, nicht als zwei getrennte Angaben. + +## Aufbau der Übersicht + +``` +┌───────────────────────────────────────────────────────────────┐ +│ LOGBUCH alle · trakk · mta360 · ... suchen ☀ │ schmal, klebend +├───────────────────────────────────────────────────────────────┤ +│ ZULETZT 7 Einträge · 14 Tage │ Augenbraue +│ │ +│ ┌───────────────┬───────────────────────────────────────────┐ │ +│ │ TRK │ FEATURE NEU │ │ Aufmacher +│ │ │ Regel-Engine: Bedingung trifft Aktion │ │ +│ │ 0142 │ Anreißer in Serifenschrift, zwei Zeilen │ │ +│ │ │ 30.07.2026 · Paul · Trakk │ │ +│ └───────────────┴───────────────────────────────────────────┘ │ +│ │ +│ ── ÄLTERE EINTRÄGE ────────────────────────────────────── │ +│ ▌MTA 0141 Spalten per Rechtsklick verwalten 29.07. │ dichte Zeilen +│ ▌TEL 0140 10.000 Tracker auf einer Karte 28.07. │ +│ ▌TRK 0139 SLA-Uhr zählt Feiertage nicht mit 25.07. │ +│ │ +│ ── ARCHIV ─────────────────────────────────────────────── │ +│ 2026 [Jan 14] [Feb 11] [Mär 18] ... │ +└───────────────────────────────────────────────────────────────┘ +``` + +Der Aufmacher ist genau ein Eintrag, der neueste. Alles darunter ist dicht und zum Überfliegen gebaut: eine Zeile pro Eintrag, links ein Balken in der Projektfarbe, dann Kürzel und Nummer in Mono, dann der Titel in Display, rechts das Datum. Bei Überfahren wächst der Balken und der Titel rückt zwei Pixel nach rechts. + +Sechs Einträge müssen ohne Scrollen sichtbar sein. Der erste Entwurf brauchte für vier Einträge einen ganzen Bildschirm, das war der Kern des Problems. + +## Aufbau der Beitragsseite + +Reihenfolge: Kennplatte klein und Projektzeile, dann Titel, dann Anreißer als Lead in `lead`, dann der Inhalt in einer Lesespalte von 38rem. Metadaten stehen **nicht** zwischen Titel und Text, sondern als eine ruhige Zeile unter dem Titel und ausführlich am Fuß des Artikels. + +Der Anreißer wird nie doppelt gezeigt. Wenn der erste Inhaltsblock denselben Text enthält, wird der Block übersprungen. + +## Fehlende Bilder + +Bilder gibt es noch nicht. Ein Eintrag ohne Bild bekommt deshalb im Aufmacher die große Kennplatte als Fläche, nicht einen leeren Rahmen und keinen Platzhalter mit Kamerasymbol. Sobald ein Beitrag ein Aufmacherbild hat, tritt die Platte auf die schmale Variante zurück und das Bild übernimmt die Fläche. + +## Bewegung + +Sparsam. Beim Laden erscheinen die Listenzeilen versetzt um je 40 Millisekunden mit 6 Pixel Versatz. Beim Überfahren wächst der Farbbalken. Sonst nichts. Bei `prefers-reduced-motion` entfällt alles. + +## Sorgfalt im Detail + +Der Auftraggeber hat den ersten Bau nicht als kaputt bezeichnet, sondern als lieblos. Das ist die genauere Kritik. Struktur allein reicht nicht, die folgenden Kleinigkeiten sind Pflicht, nicht Kür: + +- **Zahlen laufen tabellarisch.** `font-variant-numeric: tabular-nums` überall, wo Nummern und Daten untereinander stehen. Eine Liste, in der die Nummern zittern, sieht ungepflegt aus. +- **Datum spricht wie ein Mensch.** Jünger als sieben Tage: "vor drei Tagen". Älter: das Datum. Im Titelattribut immer das genaue Datum. +- **Lesedauer** je Beitrag, geschätzt aus der Textmenge, in der Metazeile. Sagt dem Leser, worauf er sich einlässt. +- **Autor als Kürzel-Marke**, zwei Buchstaben in einem kleinen Feld in Tintenfarbe. Kein Bild, keine leere Silhouette. +- **Leerzustände sagen, was als Nächstes zu tun ist.** Nicht "Keine Einträge", sondern was der Leser jetzt tun kann, mit einem Weg dorthin. +- **Trennlinien nur zwischen Gleichartigem.** Der letzte Eintrag einer Liste bekommt keine Linie nach unten. +- **Überschriften brechen ausgeglichen**, `text-wrap: balance` für Titel, `pretty` für Fließtext. Keine einzelnen Wörter in der letzten Zeile. +- **Textauswahl** trägt die Signalfarbe, nicht das Browserblau. +- **Sichtbarer Tastaturfokus** in Signalfarbe, nicht der Standardrahmen des Browsers. +- **Überfahren fühlt sich an**: der Farbbalken wächst, der Titel rückt zwei Pixel, beides in 120 Millisekunden. +- **Die 404-Seite** ist gestaltet und trägt eine eigene Zeile, keine Standardmeldung. +- **Der Aufmacher bekommt genau einen Schatten**, sehr weich, damit er von der Fläche abhebt. Sonst gibt es im ganzen Entwurf keine Schatten. + +Wenn eine dieser Kleinigkeiten fehlt, ist die Arbeit nicht fertig, auch wenn die Seite lädt. + +## Qualitätsboden + +Bis 22rem Breite benutzbar, sichtbarer Tastaturfokus, Kontrast mindestens 4.5 zu 1 für Fließtext, keine nativen Scrollbars, hell und dunkel aus denselben Tokens. diff --git a/docs/design/mockup-overview.html b/docs/design/mockup-overview.html new file mode 100644 index 0000000..b5aa891 --- /dev/null +++ b/docs/design/mockup-overview.html @@ -0,0 +1,546 @@ + + + + + +Logbuch + + + + +
+
Logbuch
+ + +
+ +
+ +
+

Was zuletzt
passiert ist.

+

Alle zwei Wochen halten wir pro Projekt fest, was fertig geworden ist. Mit Bildern, in ganzen Sätzen, ohne Ticketnummern-Kauderwelsch.

+ +
+
FlotteNYO · Pocket Rocket · Tajo · Aliens Exist
+
Zeitraum01.01.2026 - 30.07.2026
+
Einträge142 gesamt, 6 in den letzten 14 Tagen
+
Stand30.07.2026, 09:14
+
+ +
+ + + + + + +
+
+ +
+ +
+
+ 30.07. + 2026 + 0142 +
+
+
+ Feature + Neu +
+

Regel-Engine: Bedingung trifft Aktion

+

Tickets reagieren jetzt selbst. Du setzt eine Bedingung und hängst eine Aktion daran: Status ändern, zuweisen, Nachricht schicken. Läuft über den Event-Bus im Worker, ohne Cronjobs.

+
+
+
+
+ Trakk + / + 3 Bilder + / + Paul +
+
+
+ +
+
+ 29.07. + 2026 + 0141 +
+
+
Verbesserung
+

Spalten per Rechtsklick verwalten

+

Rechtsklick auf eine Kopfzeile öffnet das Spaltenmenü: sortieren, anpinnen, ausblenden, Breite zurücksetzen. Wer das nicht mag, schaltet es in den Einstellungen ab.

+
+
+
+
+
+ MTA360 + / + 2 Bilder +
+
+
+ +
+
+ 28.07. + 2026 + 0140 +
+
+
Feature
+

10.000 Tracker auf einer Karte

+

Positionen kommen gebündelt aus TimescaleDB, Geofences werden serverseitig geprüft. Die Karte bleibt bei voller Flotte flüssig.

+
+
+
+
+
+
+ MTA Telematik + / + 3 Bilder +
+
+
+ +
+
+ 25.07. + 2026 + 0139 +
+
+
Fix
+

SLA-Uhr zählt Feiertage nicht mehr mit

+

Reaktionszeiten liefen über Wochenenden und Feiertage weiter und lösten falsche Eskalationen aus. Der Kalender pro Kunde gilt jetzt auch für die Uhr.

+
+ Trakk + / + gelesen +
+
+
+ +
+
+ 23.07. + 2026 + 0138 +
+
+
Breaking
+

Lokale Tabellen-Konfigurationen werden nicht mehr gelesen

+

Ansichten liegen jetzt vollständig in der Datenbank. Wer noch eine Konfiguration im Browser hatte, sieht die Standardansicht und legt seine Ansicht einmal neu an.

+
+ MTA360 + / + gelesen +
+
+
+ +
+
+ 21.07. + 2026 + 0137 +
+
+
Info
+

Ein Eingang für alle Kommentare

+

Kommentare aus Instagram, LinkedIn und Facebook landen in einem Eingang, Antworten gehen von dort zurück.

+
+ Orbit + / + gelesen +
+
+
+ +
+ +
+ +
+ 2026 +
+ + + + + + + +
+
+
+ 2025 +
+ + + +
+
+
+ +
+ +

Dieselben Einträge holt sich jede App über die API und zeigt sie im eigenen Design. Gelesen wird pro Nutzer der jeweiligen App gemerkt.

+
+
+ Was ist neu in Trakk + 3 + Esc +
+
    +
  • + 30.07. · Feature + Regel-Engine: Bedingung trifft Aktion +

    Tickets reagieren jetzt selbst, ohne Cronjobs.

    +
  • +
  • + 25.07. · Fix + SLA-Uhr zählt Feiertage nicht mehr mit +

    Keine falschen Eskalationen über Wochenenden.

    +
  • +
  • + 18.07. · Verbesserung + Anhänge direkt im Ticket +

    Dateien landen per Drag & Drop im Verlauf.

    +
  • +
+
+ + +
+
+
+ +
+ Logbuch + Spaceport Adventures + / + Entwurf 30.07.2026 +
+ +
+ + + + + diff --git a/docs/status.md b/docs/status.md new file mode 100644 index 0000000..afc8727 --- /dev/null +++ b/docs/status.md @@ -0,0 +1,73 @@ +# Logbuch Umsetzungsstand + +Stand: 30.07.2026 + +## Plan 1, Fundament: fertig, lokal, nicht in git + +| Nachweis | Ergebnis | +|---|---| +| `pnpm test` | 11 Dateien, 112 Tests grün | +| `pnpm typecheck` | ohne Fehler | +| `pnpm build` | erfolgreich, 7 Routen | +| Live gegen `localhost:4700` | Lese-API, gelesen-Status und Sichtbarkeit von Hand geprüft | +| Mutationsprobe an 6 Wächtern | alle 6 werden von Tests gefangen | + +Es gibt weiterhin kein Repository. Kein `git init`, kein Commit, bis der Auftraggeber die laufende Anwendung lokal abgenommen hat. + +## Abweichungen vom Plan, unterwegs entstanden + +| Punkt | Was war | Was gilt | +|---|---|---| +| Postgres-Image | Volume an `/var/lib/postgresql/data` | Postgres 18 verlangt den Mount an `/var/lib/postgresql`, sonst startet der Container nicht | +| TypeScript | 7.0.2 installiert | Next 16.2.12 kommt mit der Compiler-API von TS 7 nicht klar und braucht `experimental.useTypeScriptCli`. Rückweg wäre TypeScript 6 | +| sharp | im Plan nicht erwähnt | Lässt sich auf dem Rechner nicht bauen, aus `onlyBuiltDependencies` entfernt. Wird erst für Bildvarianten in Plan 5 gebraucht und muss dort geklärt werden | +| `tsconfig.json` | im Plan mit Tabs | Next schreibt die Datei beim Start selbst um, sie ist werkzeuggesteuert | +| `findPost` | nur Slug und Zielgruppe | zusätzlich optionale Projektbindung, weil Slugs nur pro Projekt eindeutig sind | +| Token-Abfrage | SQL lag in `src/lib/api-auth.ts` | verschoben nach `src/data/repositories/clients.ts`, damit die eigene Regel gilt: kein SQL außerhalb von `src/data` | + +## Behobene Mängel aus dem Review + +Fünf Prüfer mit getrennten Blickwinkeln haben 34 Funde gemeldet, 14 wurden gegnerisch geprüft, 9 bestätigt, 5 widerlegt. Vier echte Ursachen: + +1. **`markRead` nahm fremde Beitrags-Kennungen ungeprüft an.** Ein an Trakk gebundener Client konnte einen MTA360-Beitrag als gelesen melden, die Zeile landete mit falschem Projekt in der Tabelle und senkte den Zähler der fremden App. Die Zeilen werden jetzt aus der Datenbank abgeleitet, eingeschränkt auf Projekt, Status `published` und die für den Client sichtbaren Zielgruppen. +2. **Unbekannte Beitrags-Kennung führte zu 500.** Der Fremdschlüssel schlug durch. Das war zusätzlich ein Auskunftskanal, weil existierende Kennungen 200 und unbekannte 500 ergaben. Jetzt antwortet der Endpunkt 200 mit `marked: 0` und verrät nichts. +3. **Seitenaufteilung ohne eindeutige Sortierung.** Bei gleichem `publishAt` war die Reihenfolge zufällig, Beiträge konnten auf zwei Seiten doppelt oder gar nicht erscheinen. Sortiert wird jetzt nach `publish_at desc nulls last`, dann nach `id`. +4. **Stillgelegte Projekte lieferten weiter Beiträge aus,** obwohl `GET /api/v1/projects` sie ausblendet. `listPosts`, `findPost` und `listUnread` prüfen jetzt `project.is_active`. + +Dazu Testlücken geschlossen: Projektbindung des Clients, vollständige Übergangsmatrix mit allen 25 Paaren, Zielgruppe bis zur HTTP-Antwort inklusive `scope public`, Form des Problem-JSON, Parametergrenzen, `since` als Gleichheitsgrenze, Schlagwort-Filter mit zwei Schlagworten ohne Doppelzählung, Seed-Idempotenz ohne Dubletten, 422 für Clients ohne Projektbindung. + +## Bewusst nicht behoben + +| Fund | Warum nicht | +|---|---| +| Beitrags-Detail liefert keine Blöcke und Medien | Plan 1 liefert genau die Listenfelder. Blöcke kommen mit dem Web-Archiv in Plan 3 | +| Schema erlaubt einen Write-Client mit `can_publish` | Es gibt noch keine Schreib-API. Die Sperre gehört in Plan 5, dort mit Test | +| Lese-Endpunkte prüfen den Client-Modus nicht | Ein Write-Client darf lesen. Die Zielgruppe schützt weiterhin, der Modus ist keine zweite Schranke | +| Kein Index deckt die Hauptabfrage der Liste | Bei dieser Datenmenge sinnlos. Gehört gemessen, nicht geraten | +| `listUnread` lädt alle ungelesenen Zeilen zum Zählen | Zählt Beiträge eines Projekts, das bleibt klein. Bei Bedarf später eine Zählabfrage | +| Seed-Token steht im Klartext in `scripts/seed.ts` | Reines Entwicklungs-Token für die lokale Datenbank, `lb_seed_trakk_read`. Darf niemals in einer erreichbaren Umgebung gelten | +| Zusammengesetzter Fremdschlüssel auf `post_read` | Die Anwendung verhindert falsche Zuordnungen jetzt zuverlässig, geprüft per Mutationsprobe. Ein zusätzlicher Datenbank-Zwang wäre Tiefenverteidigung und kostet eine Migration plus zusätzlichen Index | + +## Lokale Abnahme + +```bash +cd /Volumes/M2mini/WORK/NYO/projects/logbuch +docker compose up -d +pnpm dev +``` + +Dann: + +```bash +curl -s -H "Authorization: Bearer lb_seed_trakk_read" "http://localhost:4700/api/v1/posts" +curl -s -H "Authorization: Bearer lb_seed_trakk_read" "http://localhost:4700/api/v1/unread?external_user_id=u-1" +curl -s -o /dev/null -w "%{http_code}\n" "http://localhost:4700/api/v1/posts" +``` + +Erwartet: zwei Trakk-Beiträge, kein interner Beitrag, ohne Token 401. + +Das Design des Web-Archivs liegt als Entwurf unter `docs/design/mockup-overview.html` und ist im Browser direkt öffenbar. + +## Nächster Schritt + +Plan 2: Auth, Admin, Block-Editor, Freigabe. Offen davor: Subdomain, und ob der Admin-Login mit better-auth per Mail und Passwort startet oder auf Portal-SSO wartet. diff --git a/docs/style-guide.md b/docs/style-guide.md new file mode 100644 index 0000000..6173c90 --- /dev/null +++ b/docs/style-guide.md @@ -0,0 +1,79 @@ +# Redaktionsleitfaden Logbuch + +Diese Datei wird über `GET /api/v1/style-guide` ausgeliefert. Wer über die API schreibt, liest sie zuerst. + +## Wofür Logbuch da ist + +Logbuch hält fest, was in den Produkten der Firmengruppe passiert ist. Alle zwei Wochen entsteht pro Projekt ein Eintrag über das, was fertig geworden ist. + +Es ist kein Änderungsprotokoll für Entwickler und keine Pressemitteilung. Es ist für Kollegen und später Kunden, die wissen wollen, was sich geändert hat, ohne Tickets oder Code zu lesen. + +## Wer liest + +Technische und nicht technische Kollegen aus allen Bereichen. Später Kunden der jeweiligen Produkte. Sie lesen nebenbei, meist weil ihre Anwendung sie auf etwas Neues hingewiesen hat. + +Daraus folgt: kein Fachjargon ohne Erklärung, keine Ticketnummern, keine Klassennamen, keine Abkürzungen, die nur im Team bekannt sind. + +## Aufbau eines Eintrags + +1. **Titel.** Sagt, was sich geändert hat. Nicht, dass sich etwas geändert hat. +2. **Anreißer.** Zwei bis drei Sätze, die den Kern nennen und was der Leser davon hat. +3. **Text.** Was war vorher, was ist jetzt, was bleibt zu tun. Meist drei bis sechs Absätze. + +Eine Fehlerbehebung darf aus zwei Sätzen bestehen. Eine neue Funktion braucht mehr, weil sie erklärt werden muss. + +## Titel + +Gut: + +- "SLA-Uhr zählt Feiertage nicht mehr mit" +- "Spalten per Rechtsklick verwalten" +- "10.000 Tracker auf einer Karte" + +Schlecht: + +- "Verbesserungen an der Tabelle" (sagt nichts) +- "Wir haben ein spannendes neues Feature gebaut" (Werbung statt Information) +- "MTT-1423 umgesetzt" (Ticketnummer, für Leser bedeutungslos) + +## Ton + +Sachlich und direkt. Aktive Verben. Der Leser wird geduzt oder gar nicht angesprochen, beides ist in Ordnung, aber nicht gemischt. + +Verboten: Werbesprache, Superlative, Ausrufezeichen, Emojis, Gedankenstriche. Immer normales Minus. Echte Umlaute, niemals ae, oe, ue. + +Nicht behaupten, dass etwas großartig ist. Beschreiben, was es tut, und den Leser selbst urteilen lassen. + +## Bilder + +Zeigen, was sich geändert hat. Kein Firmenlogo, keine Symbolbilder, keine Menschen mit Laptop. + +Jedes Bild bekommt einen Alt-Text, der beschreibt, was zu sehen ist. Bei öffentlichen Beiträgen ist der Alt-Text Pflicht, sonst lässt sich der Beitrag nicht veröffentlichen. + +## Zielgruppe wählen + +| Zielgruppe | Wofür | +|---|---| +| `internal` | Umbauten, Zwischenstände, alles über Kunden, alles Unfertige | +| `customer` | Was ein Kunde des Produkts wissen soll | +| `public` | Was auch außerhalb der Firma stehen darf | + +Im Zweifel `internal`. Hochstufen kann ein Mensch beim Freigeben, herunterstufen ist zu spät, wenn es schon draußen war. + +## Beitragsarten + +Die Arten werden im Adminbereich verwaltet und sind über `GET /api/v1/post-types` abrufbar. Standardmäßig gibt es: + +| Schlüssel | Wofür | +|---|---| +| `feature` | Etwas Neues, das es vorher nicht gab | +| `improvement` | Etwas Bestehendes wurde besser | +| `fix` | Ein Fehler ist behoben | +| `breaking` | Eine Änderung, die Nutzer zum Handeln zwingt | +| `info` | Hinweis ohne Produktänderung | + +## Wer veröffentlicht + +Nicht der API-Zugang. Ein über die API verfasster Beitrag ist immer ein Entwurf und damit ein Vorschlag. Ein Mensch liest ihn und gibt frei. + +Deshalb: lieber einen Entwurf zu viel als einen fehlenden. Aber keine halben Sachen einreichen, die jemand anderes zu Ende schreiben muss. diff --git a/docs/superpowers/plans/2026-07-30-logbuch-fundament.md b/docs/superpowers/plans/2026-07-30-logbuch-fundament.md new file mode 100644 index 0000000..a75666d --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-logbuch-fundament.md @@ -0,0 +1,2140 @@ +# Logbuch Fundament Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Datenfundament und Lese-API von Logbuch: Projekte und Beiträge liegen in Postgres, die Sichtbarkeitsregel greift serverseitig, und eine fremde Anwendung kann ihre Beiträge samt gelesen-Status über die API abrufen. + +**Architecture:** Vier Schichten mit klaren Grenzen. `src/domain` enthält reine Funktionen ohne Framework und Datenbank (Sichtbarkeit, Statusübergänge, Veröffentlichungsprüfungen). `src/data` enthält Drizzle-Schema und Repositories, das einzige SQL im Projekt. `src/app/api/v1` ist die HTTP-Grenze mit zod-Validierung, Token-Prüfung und Problem-JSON. UI kommt erst in Plan 2 und 3. + +**Tech Stack:** Next.js 16 (App Router), React 19, TypeScript, PostgreSQL 18, Drizzle ORM 0.45, zod 4, Vitest, Tailwind 4, pnpm. + +## Global Constraints + +Diese Regeln gelten für jede Aufgabe in diesem Plan, ohne dass sie dort wiederholt werden. + +- Paketmanager ist ausschließlich `pnpm`, niemals `npm` oder `yarn`. +- Einrückung mit Tabs, Tabbreite 4. Ausnahme YAML: 2 Leerzeichen. +- Keine Kommentare im Code. Wenn Code erklärt werden muss, ist er falsch benannt. +- Keine Emojis, weder in Code noch in Oberflächen noch in Commit-Messages. +- Keine Gedankenstriche in Texten und Copy, immer normales Minus. +- Echte Umlaute in allen deutschen Texten, nie `ae`, `oe`, `ue`, `ss`. +- Keine Pixelangaben in CSS, Größen und Abstände über `rem` oder die Tailwind-Skala. +- Alle Oberflächentexte über next-intl, keine festverdrahteten Zeichenketten. Betrifft in diesem Plan nur Fehlertexte in API-Antworten: diese bleiben englische Maschinencodes, die Anzeige übersetzt später das Frontend. +- Bei jeder Unsicherheit zu einer Bibliotheks-API zuerst die offizielle Dokumentation prüfen, niemals aus dem Gedächtnis schreiben. +- **Kein git in diesem Plan.** Kein `git init`, kein `git add`, keine Commits, kein Remote. Alles bleibt lokal, bis der Auftraggeber die laufende App auf seinem Rechner gesehen und abgenommen hat. Erst danach wird ein Repository angelegt und der Stand als ein Commit gesichert. +- Statt Commits endet jede Aufgabe mit einem grünen Testlauf. Der Nachweis ist die Ausgabe, nicht die Historie. +- Versionen, am 30.07.2026 gegen die Registry geprüft: Next `16.2.12`, React `19.2.8`, Drizzle ORM `0.45.2`, drizzle-kit `0.31.10`, zod `4.4.3`, Tailwind `4.3.3`, Vitest `4.1.10`, pg `8.22.0`, Postgres-Image `18`. Vor dem Installieren erneut `npm view version` prüfen, nie eine Version aus dem Gedächtnis setzen. + +## Referenzen + +- Spec: `docs/superpowers/specs/2026-07-30-logbuch-design.md` +- Design-Mockup: `docs/design/mockup-overview.html` + +## Umfang dieses Plans + +Enthalten: Projekt-Setup, Datenbank-Schema, Domain-Logik für Sichtbarkeit und Status, Repositories, Lese-API mit Token-Prüfung, gelesen-Status, Seed-Daten. + +Nicht enthalten und in Folgeplänen: Admin und Editor (Plan 2), Web-Archiv (Plan 3), Client-Pakete für React und Vue (Plan 4), Schreib-API mit Medien-Upload und Import-Vorschläge (Plan 5). + +## Dateistruktur + +| Datei | Verantwortung | +|---|---| +| `src/domain/audience.ts` | Zielgruppen-Rangfolge und Filterentscheidung | +| `src/domain/post-status.ts` | Erlaubte Statusübergänge | +| `src/domain/publish-checks.ts` | Prüfungen vor Veröffentlichung, getrennt in Sperre und Hinweis | +| `src/domain/types.ts` | Geteilte Domain-Typen ohne Datenbankbezug | +| `src/data/schema/brands.ts` | Tabellen `brand`, `project` | +| `src/data/schema/posts.ts` | Tabellen `post`, `block`, `issue`, `tag`, `post_tag` | +| `src/data/schema/media.ts` | Tabelle `media` | +| `src/data/schema/clients.ts` | Tabellen `api_client`, `post_read` | +| `src/data/schema/index.ts` | Sammelexport aller Tabellen und Enums | +| `src/data/db.ts` | Datenbankverbindung | +| `src/data/repositories/projects.ts` | Abfragen auf `brand` und `project` | +| `src/data/repositories/posts.ts` | Abfragen auf `post` mit Filtern und Seiten | +| `src/data/repositories/reads.ts` | Lesen und Schreiben von `post_read` | +| `src/data/repositories/clients.ts` | Token-Auflösung für `api_client` | +| `src/lib/problem.ts` | Einheitliche Fehlerantwort als Problem-JSON | +| `src/lib/api-auth.ts` | Bearer-Token aus dem Request auflösen | +| `src/app/api/v1/projects/route.ts` | `GET /api/v1/projects` | +| `src/app/api/v1/posts/route.ts` | `GET /api/v1/posts` | +| `src/app/api/v1/posts/[slug]/route.ts` | `GET /api/v1/posts/:slug` | +| `src/app/api/v1/unread/route.ts` | `GET /api/v1/unread` | +| `src/app/api/v1/read/route.ts` | `POST /api/v1/read` | +| `scripts/seed.ts` | Beispieldaten aus dem Mockup | +| `tests/setup.ts` | Testdatenbank migrieren und leeren | + +--- + +### Task 1: Projekt-Setup + +**Files:** +- Create: `package.json`, `tsconfig.json`, `next.config.ts`, `postcss.config.mjs`, `vitest.config.ts`, `drizzle.config.ts`, `docker-compose.yml`, `.env.example`, `.gitignore`, `src/app/layout.tsx`, `src/app/page.tsx`, `src/app/globals.css`, `tests/smoke.test.ts` + +**Interfaces:** +- Consumes: nichts +- Produces: laufender Dev-Server auf Port 4700, `pnpm test` startet Vitest, `DATABASE_URL` und `DATABASE_URL_TEST` als Umgebungsvariablen + +- [x] **Step 1: Arbeitsverzeichnis festlegen** + +```bash +cd /Volumes/M2mini/WORK/NYO/projects/logbuch +ls docs +``` + +Erwartet: `design`, `superpowers`. Kein `git init`, siehe Global Constraints. + +- [x] **Step 2: Abhängigkeiten installieren** + +```bash +pnpm init +pnpm add next@16.2.12 react@19.2.8 react-dom@19.2.8 drizzle-orm@0.45.2 pg@8.22.0 zod@4.4.3 +pnpm add -D typescript @types/node @types/react @types/react-dom @types/pg drizzle-kit vitest tsx tailwindcss@^4 @tailwindcss/postcss dotenv +``` + +- [x] **Step 3: `package.json` Skripte setzen** + +```json +{ + "name": "logbuch", + "private": true, + "type": "module", + "scripts": { + "dev": "next dev --port 4700", + "build": "next build", + "start": "next start --port 4700", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate", + "db:seed": "tsx scripts/seed.ts" + } +} +``` + +- [x] **Step 4: `tsconfig.json` anlegen** + +```json +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "ES2022"], + "module": "esnext", + "moduleResolution": "bundler", + "jsx": "preserve", + "strict": true, + "noUncheckedIndexedAccess": true, + "noEmit": true, + "esModuleInterop": true, + "skipLibCheck": true, + "incremental": true, + "resolveJsonModule": true, + "allowJs": false, + "plugins": [{ "name": "next" }], + "paths": { "~/*": ["./src/*"] } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} +``` + +- [x] **Step 5: Postgres über Docker bereitstellen** + +`docker-compose.yml`, YAML mit 2 Leerzeichen: + +```yaml +services: + db: + image: postgres:18 + environment: + POSTGRES_USER: logbuch + POSTGRES_PASSWORD: logbuch + POSTGRES_DB: logbuch + ports: + - "5442:5432" + volumes: + - logbuch-db:/var/lib/postgresql/data + +volumes: + logbuch-db: +``` + +`.env.example`: + +``` +DATABASE_URL=postgres://logbuch:logbuch@localhost:5442/logbuch +DATABASE_URL_TEST=postgres://logbuch:logbuch@localhost:5442/logbuch_test +``` + +Starten und Testdatenbank anlegen: + +```bash +cp .env.example .env +docker compose up -d +docker compose exec db psql -U logbuch -d logbuch -c "CREATE DATABASE logbuch_test" +``` + +- [x] **Step 6: Drizzle konfigurieren** + +`drizzle.config.ts`: + +```ts +import { defineConfig } from 'drizzle-kit' +import 'dotenv/config' + +export default defineConfig({ + dialect: 'postgresql', + schema: './src/data/schema/index.ts', + out: './drizzle', + dbCredentials: { url: process.env.DATABASE_URL! }, +}) +``` + +- [x] **Step 7: Tailwind und Tokens anlegen** + +`postcss.config.mjs`: + +```js +export default { + plugins: { '@tailwindcss/postcss': {} }, +} +``` + +`src/app/globals.css`. Die Farbwerte stammen aus `docs/design/mockup-overview.html`. Hell ist Standard, Dunkel tauscht dieselben Variablen, damit Komponenten keine eigenen Dunkel-Regeln brauchen: + +```css +@import "tailwindcss"; + +@theme { + --color-paper: #eff0ec; + --color-surface: #fbfbf9; + --color-ink: #191c20; + --color-ink-2: #4a4f56; + --color-ink-3: #868b92; + --color-rule: #d8d9d2; + --color-signal: #c43a22; + --color-link: #2b4a9b; + --font-display: "Futura", "Avenir Next", system-ui, sans-serif; + --font-body: "Charter", "Iowan Old Style", Georgia, serif; + --font-mono: "SF Mono", ui-monospace, Menlo, monospace; +} + +@media (prefers-color-scheme: dark) { + :root { + --color-paper: #15171a; + --color-surface: #1c1f23; + --color-ink: #e9e8e3; + --color-ink-2: #a8adb4; + --color-ink-3: #73787f; + --color-rule: #2c3036; + --color-signal: #e46045; + --color-link: #8aa6ee; + } +} + +:root[data-theme="dark"] { + --color-paper: #15171a; + --color-surface: #1c1f23; + --color-ink: #e9e8e3; + --color-ink-2: #a8adb4; + --color-ink-3: #73787f; + --color-rule: #2c3036; + --color-signal: #e46045; + --color-link: #8aa6ee; +} + +body { + background: var(--color-paper); + color: var(--color-ink); + font-family: var(--font-body); +} +``` + +Bevor diese Datei geschrieben wird: die Tailwind-4-Dokumentation zu `@theme` und zum Dunkelmodus über ein `data`-Attribut prüfen unter https://tailwindcss.com/docs/theme und https://tailwindcss.com/docs/dark-mode. Der Umschalter über `data-theme` kommt in Plan 3, hier zählt nur, dass die Tokens greifen. Die Datei bleibt dauerhaft unter 150 Zeilen. + +- [x] **Step 8: Minimale App-Hülle anlegen** + +`src/app/layout.tsx`: + +```tsx +import './globals.css' +import type { ReactNode } from 'react' + +export const metadata = { title: 'Logbuch' } + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} +``` + +`src/app/page.tsx`: + +```tsx +export default function Page() { + return
Logbuch
+} +``` + +- [x] **Step 9: Vitest konfigurieren** + +`vitest.config.ts`: + +```ts +import { defineConfig } from 'vitest/config' +import { fileURLToPath } from 'node:url' + +export default defineConfig({ + test: { + environment: 'node', + setupFiles: ['./tests/setup.ts'], + fileParallelism: false, + }, + resolve: { + alias: { '~': fileURLToPath(new URL('./src', import.meta.url)) }, + }, +}) +``` + +`tests/setup.ts` vorläufig, wird in Task 2 erweitert: + +```ts +import 'dotenv/config' +``` + +`tests/smoke.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' + +describe('setup', () => { + it('liest die Testdatenbank-Adresse aus der Umgebung', () => { + expect(process.env.DATABASE_URL_TEST).toContain('logbuch_test') + }) +}) +``` + +- [x] **Step 10: Tests und Build prüfen** + +```bash +pnpm test +pnpm typecheck +pnpm dev +``` + +Erwartet: Vitest grün, `typecheck` ohne Fehler, Dev-Server liefert auf http://localhost:4700 die Seite mit dem Wort Logbuch. + +--- + +### Task 2: Schema für Marken und Projekte + +**Files:** +- Create: `src/data/schema/brands.ts`, `src/data/schema/index.ts`, `src/data/db.ts`, `src/data/repositories/projects.ts`, `tests/data/projects.test.ts` +- Modify: `tests/setup.ts` + +**Interfaces:** +- Consumes: `DATABASE_URL_TEST` aus Task 1 +- Produces: `db`, Tabellen `brands`, `projects`, Funktionen `listBrands(): Promise` und `listProjects(): Promise`, Typen `Brand` und `Project` + +- [x] **Step 1: Datenbankverbindung anlegen** + +`src/data/db.ts`: + +```ts +import { drizzle } from 'drizzle-orm/node-postgres' +import { Pool } from 'pg' +import * as schema from './schema' + +const url = process.env.NODE_ENV === 'test' ? process.env.DATABASE_URL_TEST : process.env.DATABASE_URL + +if (!url) { + throw new Error('DATABASE_URL is not set') +} + +export const pool = new Pool({ connectionString: url }) +export const db = drizzle(pool, { schema }) +export type Db = typeof db +``` + +- [x] **Step 2: Tabellen anlegen** + +`src/data/schema/brands.ts`: + +```ts +import { boolean, integer, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core' + +export const brands = pgTable('brand', { + id: uuid('id').primaryKey().defaultRandom(), + slug: text('slug').notNull().unique(), + name: text('name').notNull(), + color: text('color').notNull().default('#191c20'), + sort: integer('sort').notNull().default(0), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}) + +export const projects = pgTable('project', { + id: uuid('id').primaryKey().defaultRandom(), + brandId: uuid('brand_id').notNull().references(() => brands.id, { onDelete: 'restrict' }), + slug: text('slug').notNull().unique(), + name: text('name').notNull(), + description: text('description'), + color: text('color').notNull().default('#191c20'), + isActive: boolean('is_active').notNull().default(true), + sort: integer('sort').notNull().default(0), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}) + +export type Brand = typeof brands.$inferSelect +export type Project = typeof projects.$inferSelect +``` + +`src/data/schema/index.ts`: + +```ts +export * from './brands' +``` + +- [x] **Step 3: Migration erzeugen und anwenden** + +```bash +pnpm db:generate +pnpm db:migrate +DATABASE_URL=$DATABASE_URL_TEST pnpm db:migrate +``` + +Erwartet: eine neue Datei unter `drizzle/`, beide Datenbanken haben die Tabellen `brand` und `project`. + +- [x] **Step 4: Testaufräumen einrichten** + +`tests/setup.ts`: + +```ts +import 'dotenv/config' +import { beforeEach } from 'vitest' +import { sql } from 'drizzle-orm' +import { db } from '~/data/db' + +process.env.NODE_ENV = 'test' + +beforeEach(async () => { + await db.execute(sql`truncate table project, brand restart identity cascade`) +}) +``` + +- [x] **Step 5: Failing Test schreiben** + +`tests/data/projects.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { db } from '~/data/db' +import { brands, projects } from '~/data/schema' +import { listProjects } from '~/data/repositories/projects' + +describe('listProjects', () => { + it('liefert aktive Projekte nach Sortierung und Namen', async () => { + const [brand] = await db.insert(brands).values({ slug: 'nyo', name: 'NYO' }).returning() + await db.insert(projects).values([ + { brandId: brand!.id, slug: 'trakk', name: 'Trakk', sort: 2 }, + { brandId: brand!.id, slug: 'mta360', name: 'MTA360', sort: 1 }, + { brandId: brand!.id, slug: 'alt', name: 'Altprojekt', isActive: false, sort: 0 }, + ]) + + const result = await listProjects() + + expect(result.map(project => project.slug)).toEqual(['mta360', 'trakk']) + }) +}) +``` + +- [x] **Step 6: Test laufen lassen und Fehlschlag prüfen** + +```bash +pnpm vitest run tests/data/projects.test.ts +``` + +Erwartet: FAIL, `listProjects` existiert nicht. + +- [x] **Step 7: Repository schreiben** + +`src/data/repositories/projects.ts`: + +```ts +import { asc, eq } from 'drizzle-orm' +import { db } from '../db' +import { brands, projects, type Brand, type Project } from '../schema' + +export async function listProjects(): Promise { + return db.select().from(projects).where(eq(projects.isActive, true)).orderBy(asc(projects.sort), asc(projects.name)) +} + +export async function listBrands(): Promise { + return db.select().from(brands).orderBy(asc(brands.sort), asc(brands.name)) +} + +export async function findProjectBySlug(slug: string): Promise { + const rows = await db.select().from(projects).where(eq(projects.slug, slug)).limit(1) + return rows[0] +} +``` + +- [x] **Step 8: Test laufen lassen** + +```bash +pnpm vitest run tests/data/projects.test.ts +``` + +Erwartet: PASS. + +--- + +### Task 3: Schema für Beiträge, Blöcke, Ausgaben, Medien + +**Files:** +- Create: `src/data/schema/posts.ts`, `src/data/schema/media.ts` +- Modify: `src/data/schema/index.ts`, `tests/setup.ts` + +**Interfaces:** +- Consumes: `brands`, `projects` aus Task 2 +- Produces: Tabellen `posts`, `blocks`, `issues`, `tags`, `postTags`, `media`, Enums `postType`, `postAudience`, `postStatus`, Typen `Post`, `Block`, `Issue`, `Media` + +- [x] **Step 1: Enums und Tabellen anlegen** + +`src/data/schema/media.ts`: + +```ts +import { integer, jsonb, pgEnum, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core' +import { projects } from './brands' + +export const mediaKind = pgEnum('media_kind', ['image', 'video']) + +export type MediaVariant = { + width: number + format: string + path: string +} + +export const media = pgTable('media', { + id: uuid('id').primaryKey().defaultRandom(), + projectId: uuid('project_id').notNull().references(() => projects.id, { onDelete: 'cascade' }), + kind: mediaKind('kind').notNull().default('image'), + originalFilename: text('original_filename').notNull(), + mime: text('mime').notNull(), + width: integer('width'), + height: integer('height'), + byteSize: integer('byte_size').notNull(), + variants: jsonb('variants').$type().notNull().default([]), + alt: text('alt'), + caption: text('caption'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}) + +export type Media = typeof media.$inferSelect +``` + +`src/data/schema/posts.ts`: + +```ts +import { index, integer, jsonb, pgEnum, pgTable, primaryKey, text, timestamp, unique, uuid } from 'drizzle-orm/pg-core' +import { projects } from './brands' +import { media } from './media' + +export const postType = pgEnum('post_type', ['feature', 'improvement', 'fix', 'breaking', 'info']) +export const postAudience = pgEnum('post_audience', ['internal', 'customer', 'public']) +export const postStatus = pgEnum('post_status', ['draft', 'review', 'scheduled', 'published', 'archived']) +export const postLocale = pgEnum('post_locale', ['de', 'en']) +export const issueStatus = pgEnum('issue_status', ['draft', 'scheduled', 'published']) + +export const issues = pgTable('issue', { + id: uuid('id').primaryKey().defaultRandom(), + projectId: uuid('project_id').notNull().references(() => projects.id, { onDelete: 'cascade' }), + title: text('title').notNull(), + periodFrom: timestamp('period_from', { withTimezone: true }), + periodTo: timestamp('period_to', { withTimezone: true }), + status: issueStatus('status').notNull().default('draft'), + publishAt: timestamp('publish_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}) + +export const posts = pgTable('post', { + id: uuid('id').primaryKey().defaultRandom(), + projectId: uuid('project_id').notNull().references(() => projects.id, { onDelete: 'cascade' }), + issueId: uuid('issue_id').references(() => issues.id, { onDelete: 'set null' }), + number: integer('number').notNull(), + slug: text('slug').notNull(), + title: text('title').notNull(), + teaser: text('teaser'), + type: postType('type').notNull().default('feature'), + audience: postAudience('audience').notNull().default('internal'), + status: postStatus('status').notNull().default('draft'), + locale: postLocale('locale').notNull().default('de'), + translationGroup: uuid('translation_group'), + publishAt: timestamp('publish_at', { withTimezone: true }), + authorName: text('author_name'), + coverMediaId: uuid('cover_media_id').references(() => media.id, { onDelete: 'set null' }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}, table => [ + unique('post_project_slug').on(table.projectId, table.slug), + unique('post_project_number').on(table.projectId, table.number), + index('post_publish_at').on(table.publishAt), + index('post_project_status').on(table.projectId, table.status), +]) + +export const blocks = pgTable('block', { + id: uuid('id').primaryKey().defaultRandom(), + postId: uuid('post_id').notNull().references(() => posts.id, { onDelete: 'cascade' }), + sort: integer('sort').notNull().default(0), + type: text('type').notNull(), + data: jsonb('data').$type>().notNull().default({}), +}, table => [index('block_post_sort').on(table.postId, table.sort)]) + +export const tags = pgTable('tag', { + id: uuid('id').primaryKey().defaultRandom(), + projectId: uuid('project_id').notNull().references(() => projects.id, { onDelete: 'cascade' }), + slug: text('slug').notNull(), + label: text('label').notNull(), +}, table => [unique('tag_project_slug').on(table.projectId, table.slug)]) + +export const postTags = pgTable('post_tag', { + postId: uuid('post_id').notNull().references(() => posts.id, { onDelete: 'cascade' }), + tagId: uuid('tag_id').notNull().references(() => tags.id, { onDelete: 'cascade' }), +}, table => [primaryKey({ columns: [table.postId, table.tagId] })]) + +export type Issue = typeof issues.$inferSelect +export type Post = typeof posts.$inferSelect +export type Block = typeof blocks.$inferSelect +export type Tag = typeof tags.$inferSelect +``` + +`src/data/schema/index.ts`: + +```ts +export * from './brands' +export * from './media' +export * from './posts' +``` + +- [x] **Step 2: Migration erzeugen und anwenden** + +```bash +pnpm db:generate +pnpm db:migrate +DATABASE_URL=$DATABASE_URL_TEST pnpm db:migrate +``` + +- [x] **Step 3: Aufräumen im Test erweitern** + +In `tests/setup.ts` die Truncate-Anweisung ersetzen: + +```ts +beforeEach(async () => { + await db.execute(sql`truncate table post_tag, tag, block, post, issue, media, project, brand restart identity cascade`) +}) +``` + +- [x] **Step 4: Prüfen, dass bestehende Tests weiter grün sind** + +```bash +pnpm test +pnpm typecheck +``` + +Erwartet: alles grün. + +--- + +### Task 4: Sichtbarkeitsregel in der Domain + +**Files:** +- Create: `src/domain/types.ts`, `src/domain/audience.ts`, `tests/domain/audience.test.ts` + +**Interfaces:** +- Consumes: nichts +- Produces: Typen `Audience = 'internal' | 'customer' | 'public'`, `ViewerScope = 'internal' | 'customer' | 'public'`, Funktionen `visibleAudiences(scope: ViewerScope): Audience[]` und `canSee(scope: ViewerScope, audience: Audience): boolean` + +- [x] **Step 1: Failing Test mit vollständiger Matrix schreiben** + +`tests/domain/audience.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { canSee, visibleAudiences } from '~/domain/audience' +import type { Audience, ViewerScope } from '~/domain/types' + +const cases: [ViewerScope, Audience, boolean][] = [ + ['internal', 'internal', true], + ['internal', 'customer', true], + ['internal', 'public', true], + ['customer', 'internal', false], + ['customer', 'customer', true], + ['customer', 'public', true], + ['public', 'internal', false], + ['public', 'customer', false], + ['public', 'public', true], +] + +describe('canSee', () => { + for (const [scope, audience, expected] of cases) { + it(`${scope} sieht ${audience}: ${expected}`, () => { + expect(canSee(scope, audience)).toBe(expected) + }) + } +}) + +describe('visibleAudiences', () => { + it('gibt für intern alle Zielgruppen zurück', () => { + expect(visibleAudiences('internal').sort()).toEqual(['customer', 'internal', 'public']) + }) + + it('gibt für Kunden keine internen Beiträge zurück', () => { + expect(visibleAudiences('customer').sort()).toEqual(['customer', 'public']) + }) + + it('gibt öffentlich nur öffentliche Beiträge zurück', () => { + expect(visibleAudiences('public')).toEqual(['public']) + }) +}) +``` + +- [x] **Step 2: Test laufen lassen und Fehlschlag prüfen** + +```bash +pnpm vitest run tests/domain/audience.test.ts +``` + +Erwartet: FAIL, Modul `~/domain/audience` fehlt. + +- [x] **Step 3: Domain-Typen und Logik schreiben** + +`src/domain/types.ts`: + +```ts +export type Audience = 'internal' | 'customer' | 'public' +export type ViewerScope = Audience +export type PostType = 'feature' | 'improvement' | 'fix' | 'breaking' | 'info' +export type PostStatus = 'draft' | 'review' | 'scheduled' | 'published' | 'archived' +``` + +`src/domain/audience.ts`: + +```ts +import type { Audience, ViewerScope } from './types' + +const openness: Record = { + internal: 0, + customer: 1, + public: 2, +} + +export function visibleAudiences(scope: ViewerScope): Audience[] { + return (Object.keys(openness) as Audience[]).filter(audience => openness[audience] >= openness[scope]) +} + +export function canSee(scope: ViewerScope, audience: Audience): boolean { + return openness[audience] >= openness[scope] +} +``` + +- [x] **Step 4: Test laufen lassen** + +```bash +pnpm vitest run tests/domain/audience.test.ts +``` + +Erwartet: PASS, alle neun Matrixfälle grün. + +--- + +### Task 5: Statusübergänge und Veröffentlichungsprüfungen + +**Files:** +- Create: `src/domain/post-status.ts`, `src/domain/publish-checks.ts`, `tests/domain/post-status.test.ts`, `tests/domain/publish-checks.test.ts` + +**Interfaces:** +- Consumes: `PostStatus`, `Audience` aus `src/domain/types.ts` +- Produces: `canTransition(from: PostStatus, to: PostStatus): boolean`, `PublishCandidate` und `checkPublish(candidate: PublishCandidate): PublishResult` mit `PublishResult = { blockers: string[]; hints: string[] }` + +- [x] **Step 1: Failing Test für Statusübergänge schreiben** + +`tests/domain/post-status.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { canTransition } from '~/domain/post-status' + +describe('canTransition', () => { + it('erlaubt den Weg vom Entwurf bis zur Veröffentlichung', () => { + expect(canTransition('draft', 'review')).toBe(true) + expect(canTransition('review', 'published')).toBe(true) + expect(canTransition('draft', 'published')).toBe(true) + expect(canTransition('draft', 'scheduled')).toBe(true) + expect(canTransition('scheduled', 'published')).toBe(true) + }) + + it('erlaubt Zurückziehen und Wiederaufnahme', () => { + expect(canTransition('published', 'archived')).toBe(true) + expect(canTransition('archived', 'draft')).toBe(true) + }) + + it('verbietet Sprünge zurück aus der Veröffentlichung in den Entwurf', () => { + expect(canTransition('published', 'draft')).toBe(false) + expect(canTransition('published', 'review')).toBe(false) + }) + + it('verbietet gleichbleibenden Status als Übergang', () => { + expect(canTransition('draft', 'draft')).toBe(false) + }) +}) +``` + +- [x] **Step 2: Test laufen lassen und Fehlschlag prüfen** + +```bash +pnpm vitest run tests/domain/post-status.test.ts +``` + +Erwartet: FAIL, Modul fehlt. + +- [x] **Step 3: Statusübergänge schreiben** + +`src/domain/post-status.ts`: + +```ts +import type { PostStatus } from './types' + +const allowed: Record = { + draft: ['review', 'scheduled', 'published', 'archived'], + review: ['draft', 'scheduled', 'published', 'archived'], + scheduled: ['draft', 'published', 'archived'], + published: ['archived'], + archived: ['draft'], +} + +export function canTransition(from: PostStatus, to: PostStatus): boolean { + return allowed[from].includes(to) +} +``` + +- [x] **Step 4: Test laufen lassen** + +```bash +pnpm vitest run tests/domain/post-status.test.ts +``` + +Erwartet: PASS. + +- [x] **Step 5: Failing Test für Veröffentlichungsprüfungen schreiben** + +Die Spec trennt Sperre und Hinweis. Gesperrt wird nur bei fehlender Zielgruppe, fehlendem Titel, leerem Beitrag und öffentlichem Beitrag ohne Alt-Text. Alles andere ist Hinweis. + +`tests/domain/publish-checks.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { checkPublish } from '~/domain/publish-checks' + +const base = { + title: 'Regel-Engine: Bedingung trifft Aktion', + audience: 'internal' as const, + blockCount: 3, + images: [{ hasAlt: true }], + tagCount: 1, + hasCover: true, +} + +describe('checkPublish', () => { + it('lässt einen vollständigen Beitrag ohne Sperre und ohne Hinweis durch', () => { + expect(checkPublish(base)).toEqual({ blockers: [], hints: [] }) + }) + + it('sperrt einen Beitrag ohne Titel', () => { + expect(checkPublish({ ...base, title: ' ' }).blockers).toContain('title_missing') + }) + + it('sperrt einen leeren Beitrag', () => { + expect(checkPublish({ ...base, blockCount: 0 }).blockers).toContain('content_empty') + }) + + it('sperrt einen öffentlichen Beitrag mit Bild ohne Alt-Text', () => { + const result = checkPublish({ ...base, audience: 'public', images: [{ hasAlt: false }] }) + expect(result.blockers).toContain('alt_text_missing') + }) + + it('meldet fehlenden Alt-Text bei internen Beiträgen nur als Hinweis', () => { + const result = checkPublish({ ...base, images: [{ hasAlt: false }] }) + expect(result.blockers).toEqual([]) + expect(result.hints).toContain('alt_text_missing') + }) + + it('meldet fehlende Schlagworte und fehlendes Aufmacherbild als Hinweis', () => { + const result = checkPublish({ ...base, tagCount: 0, hasCover: false }) + expect(result.blockers).toEqual([]) + expect(result.hints).toEqual(expect.arrayContaining(['tags_missing', 'cover_missing'])) + }) +}) +``` + +- [x] **Step 6: Test laufen lassen und Fehlschlag prüfen** + +```bash +pnpm vitest run tests/domain/publish-checks.test.ts +``` + +Erwartet: FAIL, Modul fehlt. + +- [x] **Step 7: Prüfungen schreiben** + +`src/domain/publish-checks.ts`: + +```ts +import type { Audience } from './types' + +export type PublishCandidate = { + title: string + audience: Audience + blockCount: number + images: { hasAlt: boolean }[] + tagCount: number + hasCover: boolean +} + +export type PublishResult = { + blockers: string[] + hints: string[] +} + +export function checkPublish(candidate: PublishCandidate): PublishResult { + const blockers: string[] = [] + const hints: string[] = [] + const imagesWithoutAlt = candidate.images.filter(image => !image.hasAlt).length + + if (candidate.title.trim().length === 0) { + blockers.push('title_missing') + } + + if (candidate.blockCount === 0) { + blockers.push('content_empty') + } + + if (imagesWithoutAlt > 0) { + if (candidate.audience === 'public') { + blockers.push('alt_text_missing') + } else { + hints.push('alt_text_missing') + } + } + + if (candidate.tagCount === 0) { + hints.push('tags_missing') + } + + if (!candidate.hasCover) { + hints.push('cover_missing') + } + + return { blockers, hints } +} +``` + +- [x] **Step 8: Test laufen lassen** + +```bash +pnpm vitest run tests/domain/publish-checks.test.ts +pnpm typecheck +``` + +Erwartet: PASS und keine Typfehler. + +--- + +### Task 6: API-Clients und Token-Prüfung + +**Files:** +- Create: `src/data/schema/clients.ts`, `src/data/repositories/clients.ts`, `src/lib/api-auth.ts`, `src/lib/problem.ts`, `tests/lib/api-auth.test.ts` +- Modify: `src/data/schema/index.ts`, `tests/setup.ts` + +**Interfaces:** +- Consumes: `projects` aus Task 2, `posts` aus Task 3, `ViewerScope` aus Task 4 +- Produces: Tabellen `apiClients`, `postReads`, `hashToken(token: string): string`, `resolveClient(request: Request): Promise`, `problem(status: number, type: string, detail?: string): Response`, Typ `ApiClient` + +- [x] **Step 1: Tabellen anlegen** + +`src/data/schema/clients.ts`: + +```ts +import { boolean, index, pgEnum, pgTable, primaryKey, text, timestamp, uuid } from 'drizzle-orm/pg-core' +import { projects } from './brands' +import { posts } from './posts' + +export const clientMode = pgEnum('client_mode', ['read', 'write']) +export const clientScope = pgEnum('client_scope', ['internal', 'customer', 'public']) + +export const apiClients = pgTable('api_client', { + id: uuid('id').primaryKey().defaultRandom(), + name: text('name').notNull(), + tokenHash: text('token_hash').notNull().unique(), + mode: clientMode('mode').notNull().default('read'), + scope: clientScope('scope').notNull().default('customer'), + projectId: uuid('project_id').references(() => projects.id, { onDelete: 'cascade' }), + canPublish: boolean('can_publish').notNull().default(false), + lastUsedAt: timestamp('last_used_at', { withTimezone: true }), + revokedAt: timestamp('revoked_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}) + +export const postReads = pgTable('post_read', { + postId: uuid('post_id').notNull().references(() => posts.id, { onDelete: 'cascade' }), + projectId: uuid('project_id').notNull().references(() => projects.id, { onDelete: 'cascade' }), + externalUserId: text('external_user_id').notNull(), + readAt: timestamp('read_at', { withTimezone: true }).notNull().defaultNow(), +}, table => [ + primaryKey({ columns: [table.postId, table.externalUserId] }), + index('post_read_project_user').on(table.projectId, table.externalUserId), +]) + +export type ApiClient = typeof apiClients.$inferSelect +``` + +In `src/data/schema/index.ts` ergänzen: + +```ts +export * from './clients' +``` + +- [x] **Step 2: Migration erzeugen und anwenden, Aufräumen erweitern** + +```bash +pnpm db:generate +pnpm db:migrate +DATABASE_URL=$DATABASE_URL_TEST pnpm db:migrate +``` + +In `tests/setup.ts` die Truncate-Anweisung ersetzen: + +```ts +beforeEach(async () => { + await db.execute(sql`truncate table post_read, api_client, post_tag, tag, block, post, issue, media, project, brand restart identity cascade`) +}) +``` + +- [x] **Step 3: Failing Test schreiben** + +`tests/lib/api-auth.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { db } from '~/data/db' +import { apiClients } from '~/data/schema' +import { hashToken, resolveClient } from '~/lib/api-auth' + +async function seedClient(token: string, revoked = false) { + const [client] = await db.insert(apiClients).values({ + name: 'Trakk Leseclient', + tokenHash: hashToken(token), + mode: 'read', + scope: 'internal', + revokedAt: revoked ? new Date() : null, + }).returning() + return client! +} + +describe('resolveClient', () => { + it('findet den Client zu einem gültigen Bearer-Token', async () => { + const client = await seedClient('lb_live_abc') + const request = new Request('http://localhost/api/v1/posts', { + headers: { authorization: 'Bearer lb_live_abc' }, + }) + + await expect(resolveClient(request)).resolves.toMatchObject({ id: client.id }) + }) + + it('liefert nichts ohne Kopfzeile', async () => { + await seedClient('lb_live_abc') + const request = new Request('http://localhost/api/v1/posts') + + await expect(resolveClient(request)).resolves.toBeUndefined() + }) + + it('liefert nichts für ein widerrufenes Token', async () => { + await seedClient('lb_live_abc', true) + const request = new Request('http://localhost/api/v1/posts', { + headers: { authorization: 'Bearer lb_live_abc' }, + }) + + await expect(resolveClient(request)).resolves.toBeUndefined() + }) + + it('speichert das Token niemals im Klartext', async () => { + await seedClient('lb_live_abc') + const rows = await db.select().from(apiClients) + + expect(rows[0]!.tokenHash).not.toContain('lb_live_abc') + }) +}) +``` + +- [x] **Step 4: Test laufen lassen und Fehlschlag prüfen** + +```bash +pnpm vitest run tests/lib/api-auth.test.ts +``` + +Erwartet: FAIL, Modul `~/lib/api-auth` fehlt. + +- [x] **Step 5: Token-Auflösung und Problem-JSON schreiben** + +`src/lib/api-auth.ts`: + +```ts +import { createHash } from 'node:crypto' +import { and, eq, isNull } from 'drizzle-orm' +import { db } from '~/data/db' +import { apiClients, type ApiClient } from '~/data/schema' + +export function hashToken(token: string): string { + return createHash('sha256').update(token).digest('hex') +} + +export async function resolveClient(request: Request): Promise { + const header = request.headers.get('authorization') + + if (!header?.startsWith('Bearer ')) { + return undefined + } + + const token = header.slice('Bearer '.length).trim() + + if (token.length === 0) { + return undefined + } + + const rows = await db + .select() + .from(apiClients) + .where(and(eq(apiClients.tokenHash, hashToken(token)), isNull(apiClients.revokedAt))) + .limit(1) + + return rows[0] +} +``` + +`src/lib/problem.ts`: + +```ts +export function problem(status: number, type: string, detail?: string): Response { + return Response.json( + { type, title: type.replace(/_/g, ' '), status, detail }, + { status, headers: { 'content-type': 'application/problem+json' } }, + ) +} +``` + +- [x] **Step 6: Test laufen lassen** + +```bash +pnpm vitest run tests/lib/api-auth.test.ts +``` + +Erwartet: PASS, alle vier Fälle grün. + +--- + +### Task 7: Beitrags-Repository mit Filtern + +**Files:** +- Create: `src/data/repositories/posts.ts`, `tests/data/posts.test.ts` + +**Interfaces:** +- Consumes: `posts`, `projects`, `tags`, `postTags` aus Tasks 2 und 3, `visibleAudiences` aus Task 4 +- Produces: `listPosts(query: PostQuery): Promise` mit `PostQuery = { scope: ViewerScope; projectSlug?: string; projectId?: string; since?: Date; type?: PostType; tag?: string; page: number; perPage: number }` und `PostListResult = { items: PostListItem[]; total: number }`, `findPost(slug: string, scope: ViewerScope): Promise` + +- [x] **Step 1: Failing Test schreiben** + +`tests/data/posts.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { db } from '~/data/db' +import { brands, posts, projects } from '~/data/schema' +import { findPost, listPosts } from '~/data/repositories/posts' + +async function seed() { + const [brand] = await db.insert(brands).values({ slug: 'pocket-rocket', name: 'Pocket Rocket' }).returning() + const [trakk] = await db.insert(projects).values({ brandId: brand!.id, slug: 'trakk', name: 'Trakk' }).returning() + const [mta] = await db.insert(projects).values({ brandId: brand!.id, slug: 'mta360', name: 'MTA360' }).returning() + + await db.insert(posts).values([ + { + projectId: trakk!.id, number: 1, slug: 'regel-engine', title: 'Regel-Engine', + type: 'feature', audience: 'customer', status: 'published', + publishAt: new Date('2026-07-30T08:00:00Z'), + }, + { + projectId: trakk!.id, number: 2, slug: 'interner-umbau', title: 'Interner Umbau', + type: 'improvement', audience: 'internal', status: 'published', + publishAt: new Date('2026-07-29T08:00:00Z'), + }, + { + projectId: trakk!.id, number: 3, slug: 'noch-entwurf', title: 'Noch Entwurf', + type: 'fix', audience: 'public', status: 'draft', + }, + { + projectId: mta!.id, number: 1, slug: 'spaltenmenue', title: 'Spaltenmenü', + type: 'improvement', audience: 'public', status: 'published', + publishAt: new Date('2026-07-28T08:00:00Z'), + }, + ]) + + return { trakk: trakk!, mta: mta! } +} + +describe('listPosts', () => { + it('liefert nur veröffentlichte Beiträge, neueste zuerst', async () => { + await seed() + + const result = await listPosts({ scope: 'internal', page: 1, perPage: 25 }) + + expect(result.items.map(item => item.slug)).toEqual(['regel-engine', 'interner-umbau', 'spaltenmenue']) + expect(result.total).toBe(3) + }) + + it('verbirgt interne Beiträge vor Kunden', async () => { + await seed() + + const result = await listPosts({ scope: 'customer', page: 1, perPage: 25 }) + + expect(result.items.map(item => item.slug)).toEqual(['regel-engine', 'spaltenmenue']) + }) + + it('filtert nach Projekt', async () => { + await seed() + + const result = await listPosts({ scope: 'internal', projectSlug: 'mta360', page: 1, perPage: 25 }) + + expect(result.items.map(item => item.slug)).toEqual(['spaltenmenue']) + }) + + it('filtert nach Zeitpunkt und Typ', async () => { + await seed() + + const since = await listPosts({ scope: 'internal', since: new Date('2026-07-29T00:00:00Z'), page: 1, perPage: 25 }) + const byType = await listPosts({ scope: 'internal', type: 'feature', page: 1, perPage: 25 }) + + expect(since.items).toHaveLength(2) + expect(byType.items.map(item => item.slug)).toEqual(['regel-engine']) + }) + + it('teilt in Seiten und meldet die Gesamtzahl', async () => { + await seed() + + const result = await listPosts({ scope: 'internal', page: 2, perPage: 2 }) + + expect(result.items.map(item => item.slug)).toEqual(['spaltenmenue']) + expect(result.total).toBe(3) + }) +}) + +describe('findPost', () => { + it('liefert einen Beitrag mit Projektangabe', async () => { + await seed() + + const post = await findPost('regel-engine', 'customer') + + expect(post?.projectSlug).toBe('trakk') + expect(post?.title).toBe('Regel-Engine') + }) + + it('liefert nichts, wenn die Zielgruppe es nicht erlaubt', async () => { + await seed() + + await expect(findPost('interner-umbau', 'customer')).resolves.toBeUndefined() + }) +}) +``` + +- [x] **Step 2: Test laufen lassen und Fehlschlag prüfen** + +```bash +pnpm vitest run tests/data/posts.test.ts +``` + +Erwartet: FAIL, Modul `~/data/repositories/posts` fehlt. + +- [x] **Step 3: Repository schreiben** + +`src/data/repositories/posts.ts`: + +```ts +import { and, count, desc, eq, gte, inArray, type SQL } from 'drizzle-orm' +import { db } from '../db' +import { posts, projects, postTags, tags } from '../schema' +import { visibleAudiences } from '~/domain/audience' +import type { PostType, ViewerScope } from '~/domain/types' + +export type PostQuery = { + scope: ViewerScope + projectSlug?: string + projectId?: string + since?: Date + type?: PostType + tag?: string + page: number + perPage: number +} + +export type PostListItem = { + id: string + slug: string + title: string + teaser: string | null + type: string + audience: string + publishAt: Date | null + number: number + projectSlug: string + projectName: string + projectColor: string +} + +export type PostListResult = { + items: PostListItem[] + total: number +} + +export type PostDetail = PostListItem + +const selection = { + id: posts.id, + slug: posts.slug, + title: posts.title, + teaser: posts.teaser, + type: posts.type, + audience: posts.audience, + publishAt: posts.publishAt, + number: posts.number, + projectSlug: projects.slug, + projectName: projects.name, + projectColor: projects.color, +} + +function conditions(query: PostQuery): SQL[] { + const parts: SQL[] = [ + eq(posts.status, 'published'), + inArray(posts.audience, visibleAudiences(query.scope)), + ] + + if (query.projectSlug) { + parts.push(eq(projects.slug, query.projectSlug)) + } + + if (query.projectId) { + parts.push(eq(posts.projectId, query.projectId)) + } + + if (query.since) { + parts.push(gte(posts.publishAt, query.since)) + } + + if (query.type) { + parts.push(eq(posts.type, query.type)) + } + + return parts +} + +export async function listPosts(query: PostQuery): Promise { + const parts = conditions(query) + const offset = (query.page - 1) * query.perPage + + if (query.tag) { + const rows = await db + .select(selection) + .from(posts) + .innerJoin(projects, eq(projects.id, posts.projectId)) + .innerJoin(postTags, eq(postTags.postId, posts.id)) + .innerJoin(tags, and(eq(tags.id, postTags.tagId), eq(tags.slug, query.tag))) + .where(and(...parts)) + .orderBy(desc(posts.publishAt)) + .limit(query.perPage) + .offset(offset) + + return { items: rows, total: rows.length } + } + + const items = await db + .select(selection) + .from(posts) + .innerJoin(projects, eq(projects.id, posts.projectId)) + .where(and(...parts)) + .orderBy(desc(posts.publishAt)) + .limit(query.perPage) + .offset(offset) + + const [totals] = await db + .select({ value: count() }) + .from(posts) + .innerJoin(projects, eq(projects.id, posts.projectId)) + .where(and(...parts)) + + return { items, total: Number(totals?.value ?? 0) } +} + +export async function findPost(slug: string, scope: ViewerScope): Promise { + const rows = await db + .select(selection) + .from(posts) + .innerJoin(projects, eq(projects.id, posts.projectId)) + .where(and( + eq(posts.slug, slug), + eq(posts.status, 'published'), + inArray(posts.audience, visibleAudiences(scope)), + )) + .limit(1) + + return rows[0] +} +``` + +- [x] **Step 4: Test laufen lassen** + +```bash +pnpm vitest run tests/data/posts.test.ts +pnpm typecheck +``` + +Erwartet: PASS. Falls der Seiten-Test wegen der Gesamtzahl beim Tag-Filter scheitert, den Tag-Zweig so ändern, dass er dieselbe Zählabfrage mit den Tag-Joins ausführt, statt `rows.length` zu melden. + +--- + +### Task 8: Lese-Endpunkte + +**Files:** +- Create: `src/app/api/v1/projects/route.ts`, `src/app/api/v1/posts/route.ts`, `src/app/api/v1/posts/[slug]/route.ts`, `tests/api/posts.test.ts` + +**Interfaces:** +- Consumes: `resolveClient` und `problem` aus Task 6, `listPosts` und `findPost` aus Task 7, `listProjects` aus Task 2 +- Produces: HTTP-Endpunkte `GET /api/v1/projects`, `GET /api/v1/posts`, `GET /api/v1/posts/:slug` + +- [x] **Step 1: Failing Test schreiben** + +Die Route-Handler werden direkt als Funktionen aufgerufen, ohne laufenden Server. + +`tests/api/posts.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { db } from '~/data/db' +import { apiClients, brands, posts, projects } from '~/data/schema' +import { hashToken } from '~/lib/api-auth' +import { GET as getPosts } from '~/app/api/v1/posts/route' + +async function seed() { + const [brand] = await db.insert(brands).values({ slug: 'pocket-rocket', name: 'Pocket Rocket' }).returning() + const [trakk] = await db.insert(projects).values({ brandId: brand!.id, slug: 'trakk', name: 'Trakk' }).returning() + + await db.insert(posts).values([ + { + projectId: trakk!.id, number: 1, slug: 'regel-engine', title: 'Regel-Engine', + audience: 'customer', status: 'published', publishAt: new Date('2026-07-30T08:00:00Z'), + }, + { + projectId: trakk!.id, number: 2, slug: 'interner-umbau', title: 'Interner Umbau', + audience: 'internal', status: 'published', publishAt: new Date('2026-07-29T08:00:00Z'), + }, + ]) + + await db.insert(apiClients).values([ + { name: 'Kundenclient', tokenHash: hashToken('lb_customer'), mode: 'read', scope: 'customer', projectId: trakk!.id }, + { name: 'Interner Client', tokenHash: hashToken('lb_internal'), mode: 'read', scope: 'internal', projectId: trakk!.id }, + ]) +} + +function request(url: string, token?: string) { + return new Request(url, { headers: token ? { authorization: `Bearer ${token}` } : {} }) +} + +describe('GET /api/v1/posts', () => { + it('antwortet 401 ohne Token', async () => { + await seed() + + const response = await getPosts(request('http://localhost/api/v1/posts')) + + expect(response.status).toBe(401) + }) + + it('gibt einem Kundenclient keine internen Beiträge, auch nicht mit Parameter', async () => { + await seed() + + const response = await getPosts(request('http://localhost/api/v1/posts?audience=internal', 'lb_customer')) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.items.map((item: { slug: string }) => item.slug)).toEqual(['regel-engine']) + }) + + it('gibt einem internen Client alle Beiträge', async () => { + await seed() + + const response = await getPosts(request('http://localhost/api/v1/posts', 'lb_internal')) + const body = await response.json() + + expect(body.items).toHaveLength(2) + expect(body.meta.total).toBe(2) + }) + + it('lehnt ungültige Parameter mit 400 ab', async () => { + await seed() + + const response = await getPosts(request('http://localhost/api/v1/posts?per_page=999', 'lb_internal')) + + expect(response.status).toBe(400) + }) + + it('begrenzt einen projektgebundenen Client auf sein Projekt', async () => { + await seed() + + const response = await getPosts(request('http://localhost/api/v1/posts?project=mta360', 'lb_customer')) + const body = await response.json() + + expect(body.items).toHaveLength(0) + }) +}) +``` + +- [x] **Step 2: Test laufen lassen und Fehlschlag prüfen** + +```bash +pnpm vitest run tests/api/posts.test.ts +``` + +Erwartet: FAIL, Route fehlt. + +- [x] **Step 3: Endpunkte schreiben** + +`src/app/api/v1/posts/route.ts`: + +```ts +import { z } from 'zod' +import { listPosts } from '~/data/repositories/posts' +import { resolveClient } from '~/lib/api-auth' +import { problem } from '~/lib/problem' + +const querySchema = z.object({ + project: z.string().min(1).optional(), + since: z.coerce.date().optional(), + type: z.enum(['feature', 'improvement', 'fix', 'breaking', 'info']).optional(), + tag: z.string().min(1).optional(), + page: z.coerce.number().int().min(1).default(1), + per_page: z.coerce.number().int().min(1).max(100).default(25), +}) + +export async function GET(request: Request) { + const client = await resolveClient(request) + + if (!client) { + return problem(401, 'unauthorized') + } + + const url = new URL(request.url) + const parsed = querySchema.safeParse(Object.fromEntries(url.searchParams)) + + if (!parsed.success) { + return problem(400, 'invalid_query', parsed.error.issues.map(issue => issue.path.join('.')).join(', ')) + } + + const query = parsed.data + const result = await listPosts({ + scope: client.scope, + projectSlug: query.project, + projectId: client.projectId ?? undefined, + since: query.since, + type: query.type, + tag: query.tag, + page: query.page, + perPage: query.per_page, + }) + + return Response.json({ + items: result.items, + meta: { total: result.total, page: query.page, per_page: query.per_page }, + }) +} +``` + +`src/app/api/v1/posts/[slug]/route.ts`: + +```ts +import { findPost } from '~/data/repositories/posts' +import { resolveClient } from '~/lib/api-auth' +import { problem } from '~/lib/problem' + +export async function GET(request: Request, context: { params: Promise<{ slug: string }> }) { + const client = await resolveClient(request) + + if (!client) { + return problem(401, 'unauthorized') + } + + const { slug } = await context.params + const post = await findPost(slug, client.scope) + + if (!post) { + return problem(404, 'post_not_found') + } + + return Response.json(post) +} +``` + +`src/app/api/v1/projects/route.ts`: + +```ts +import { listProjects } from '~/data/repositories/projects' +import { resolveClient } from '~/lib/api-auth' +import { problem } from '~/lib/problem' + +export async function GET(request: Request) { + const client = await resolveClient(request) + + if (!client) { + return problem(401, 'unauthorized') + } + + const all = await listProjects() + const items = client.projectId ? all.filter(project => project.id === client.projectId) : all + + return Response.json({ items }) +} +``` + +Der Parameter `audience` wird bewusst nicht gelesen. Die Zielgruppe kommt ausschließlich aus dem Client. + +- [x] **Step 4: Test laufen lassen** + +```bash +pnpm vitest run tests/api/posts.test.ts +pnpm typecheck +``` + +Erwartet: PASS, insbesondere der Fall mit `?audience=internal`, der weiterhin nur den Kundenbeitrag liefert. + +--- + +### Task 9: Gelesen-Status + +**Files:** +- Create: `src/data/repositories/reads.ts`, `src/app/api/v1/unread/route.ts`, `src/app/api/v1/read/route.ts`, `tests/api/reads.test.ts` + +**Interfaces:** +- Consumes: `postReads` aus Task 6, `listPosts` aus Task 7, `resolveClient` und `problem` aus Task 6 +- Produces: `countUnread(args: { projectId: string; externalUserId: string; scope: ViewerScope }): Promise`, `listUnread(args: same): Promise`, `markRead(args: { projectId: string; externalUserId: string; postIds: string[] }): Promise`, Endpunkte `GET /api/v1/unread` und `POST /api/v1/read` + +- [x] **Step 1: Failing Test schreiben** + +`tests/api/reads.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { db } from '~/data/db' +import { apiClients, brands, posts, projects } from '~/data/schema' +import { hashToken } from '~/lib/api-auth' +import { GET as getUnread } from '~/app/api/v1/unread/route' +import { POST as postRead } from '~/app/api/v1/read/route' + +async function seed() { + const [brand] = await db.insert(brands).values({ slug: 'pocket-rocket', name: 'Pocket Rocket' }).returning() + const [trakk] = await db.insert(projects).values({ brandId: brand!.id, slug: 'trakk', name: 'Trakk' }).returning() + + const inserted = await db.insert(posts).values([ + { + projectId: trakk!.id, number: 1, slug: 'regel-engine', title: 'Regel-Engine', + audience: 'customer', status: 'published', publishAt: new Date('2026-07-30T08:00:00Z'), + }, + { + projectId: trakk!.id, number: 2, slug: 'sla-uhr', title: 'SLA-Uhr', + audience: 'customer', status: 'published', publishAt: new Date('2026-07-25T08:00:00Z'), + }, + ]).returning() + + await db.insert(apiClients).values({ + name: 'Trakk', tokenHash: hashToken('lb_trakk'), mode: 'read', scope: 'customer', projectId: trakk!.id, + }) + + return { postIds: inserted.map(post => post.id) } +} + +function get(url: string) { + return new Request(url, { headers: { authorization: 'Bearer lb_trakk' } }) +} + +function post(body: unknown) { + return new Request('http://localhost/api/v1/read', { + method: 'POST', + headers: { authorization: 'Bearer lb_trakk', 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +describe('gelesen-Status', () => { + it('zählt zuerst alle Beiträge als ungelesen', async () => { + await seed() + + const response = await getUnread(get('http://localhost/api/v1/unread?external_user_id=u-42')) + const body = await response.json() + + expect(body.count).toBe(2) + expect(body.items).toHaveLength(2) + }) + + it('meldet Beiträge als gelesen und zählt danach weniger', async () => { + const { postIds } = await seed() + + const marked = await postRead(post({ external_user_id: 'u-42', post_ids: [postIds[0]] })) + const response = await getUnread(get('http://localhost/api/v1/unread?external_user_id=u-42')) + const body = await response.json() + + expect(marked.status).toBe(200) + expect(body.count).toBe(1) + expect(body.items[0].slug).toBe('sla-uhr') + }) + + it('bleibt bei doppelter Meldung stabil', async () => { + const { postIds } = await seed() + + await postRead(post({ external_user_id: 'u-42', post_ids: [postIds[0]] })) + const second = await postRead(post({ external_user_id: 'u-42', post_ids: [postIds[0]] })) + + expect(second.status).toBe(200) + }) + + it('trennt Nutzer voneinander', async () => { + const { postIds } = await seed() + + await postRead(post({ external_user_id: 'u-42', post_ids: postIds })) + const response = await getUnread(get('http://localhost/api/v1/unread?external_user_id=u-99')) + const body = await response.json() + + expect(body.count).toBe(2) + }) + + it('verlangt eine Nutzerkennung', async () => { + await seed() + + const response = await getUnread(get('http://localhost/api/v1/unread')) + + expect(response.status).toBe(400) + }) +}) +``` + +- [x] **Step 2: Test laufen lassen und Fehlschlag prüfen** + +```bash +pnpm vitest run tests/api/reads.test.ts +``` + +Erwartet: FAIL, Module fehlen. + +- [x] **Step 3: Repository schreiben** + +`src/data/repositories/reads.ts`: + +```ts +import { and, desc, eq, inArray, notExists, sql } from 'drizzle-orm' +import { db } from '../db' +import { postReads, posts, projects } from '../schema' +import { visibleAudiences } from '~/domain/audience' +import type { ViewerScope } from '~/domain/types' +import type { PostListItem } from './posts' + +type UnreadArgs = { + projectId: string + externalUserId: string + scope: ViewerScope +} + +export async function listUnread(args: UnreadArgs): Promise { + return db + .select({ + id: posts.id, + slug: posts.slug, + title: posts.title, + teaser: posts.teaser, + type: posts.type, + audience: posts.audience, + publishAt: posts.publishAt, + number: posts.number, + projectSlug: projects.slug, + projectName: projects.name, + projectColor: projects.color, + }) + .from(posts) + .innerJoin(projects, eq(projects.id, posts.projectId)) + .where(and( + eq(posts.projectId, args.projectId), + eq(posts.status, 'published'), + inArray(posts.audience, visibleAudiences(args.scope)), + notExists( + db.select({ one: sql`1` }).from(postReads).where(and( + eq(postReads.postId, posts.id), + eq(postReads.externalUserId, args.externalUserId), + )), + ), + )) + .orderBy(desc(posts.publishAt)) +} + +export async function markRead(args: { projectId: string; externalUserId: string; postIds: string[] }): Promise { + if (args.postIds.length === 0) { + return 0 + } + + const rows = args.postIds.map(postId => ({ + postId, + projectId: args.projectId, + externalUserId: args.externalUserId, + })) + + await db.insert(postReads).values(rows).onConflictDoNothing() + + return rows.length +} +``` + +- [x] **Step 4: Endpunkte schreiben** + +`src/app/api/v1/unread/route.ts`: + +```ts +import { z } from 'zod' +import { listUnread } from '~/data/repositories/reads' +import { resolveClient } from '~/lib/api-auth' +import { problem } from '~/lib/problem' + +const querySchema = z.object({ external_user_id: z.string().min(1) }) + +export async function GET(request: Request) { + const client = await resolveClient(request) + + if (!client) { + return problem(401, 'unauthorized') + } + + if (!client.projectId) { + return problem(422, 'client_without_project') + } + + const url = new URL(request.url) + const parsed = querySchema.safeParse(Object.fromEntries(url.searchParams)) + + if (!parsed.success) { + return problem(400, 'invalid_query', 'external_user_id') + } + + const items = await listUnread({ + projectId: client.projectId, + externalUserId: parsed.data.external_user_id, + scope: client.scope, + }) + + return Response.json({ count: items.length, items }) +} +``` + +`src/app/api/v1/read/route.ts`: + +```ts +import { z } from 'zod' +import { markRead } from '~/data/repositories/reads' +import { resolveClient } from '~/lib/api-auth' +import { problem } from '~/lib/problem' + +const bodySchema = z.object({ + external_user_id: z.string().min(1), + post_ids: z.array(z.uuid()).min(1).max(200), +}) +``` + +zod 4 hat die Prüfungen für Formate auf oberste Ebene gezogen, `z.uuid()` statt `z.string().uuid()`. Vor dem Schreiben unter https://zod.dev bestätigen, weil beide Formen in Umlauf sind. + +```ts + +export async function POST(request: Request) { + const client = await resolveClient(request) + + if (!client) { + return problem(401, 'unauthorized') + } + + if (!client.projectId) { + return problem(422, 'client_without_project') + } + + const parsed = bodySchema.safeParse(await request.json().catch(() => null)) + + if (!parsed.success) { + return problem(400, 'invalid_body', parsed.error.issues.map(issue => issue.path.join('.')).join(', ')) + } + + const marked = await markRead({ + projectId: client.projectId, + externalUserId: parsed.data.external_user_id, + postIds: parsed.data.post_ids, + }) + + return Response.json({ marked }) +} +``` + +- [x] **Step 5: Test laufen lassen** + +```bash +pnpm vitest run tests/api/reads.test.ts +pnpm typecheck +``` + +Erwartet: PASS. + +--- + +### Task 10: Seed-Daten + +**Files:** +- Create: `scripts/seed.ts`, `tests/scripts/seed.test.ts` + +**Interfaces:** +- Consumes: alle Tabellen aus Tasks 2, 3 und 6 +- Produces: `seed(): Promise`, ausführbar über `pnpm db:seed` + +- [x] **Step 1: Failing Test schreiben** + +`tests/scripts/seed.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { db } from '~/data/db' +import { posts, projects } from '~/data/schema' +import { seed } from '../../scripts/seed' + +describe('seed', () => { + it('legt Marken, Projekte und Beiträge an', async () => { + await seed() + + const projectRows = await db.select().from(projects) + const postRows = await db.select().from(posts) + + expect(projectRows.map(project => project.slug)).toEqual( + expect.arrayContaining(['trakk', 'mta360', 'mta-telematik', 'orbit', 'pulsar']), + ) + expect(postRows.length).toBeGreaterThanOrEqual(6) + expect(postRows.every(post => post.number > 0)).toBe(true) + }) + + it('ist zweimal hintereinander ausführbar', async () => { + await seed() + await expect(seed()).resolves.toBeUndefined() + }) +}) +``` + +- [x] **Step 2: Test laufen lassen und Fehlschlag prüfen** + +```bash +pnpm vitest run tests/scripts/seed.test.ts +``` + +Erwartet: FAIL, `scripts/seed.ts` fehlt. + +- [x] **Step 3: Seed schreiben** + +Inhalte übernommen aus `docs/design/mockup-overview.html`, damit Archiv und Admin später mit echtem Text arbeiten. + +`scripts/seed.ts`: + +```ts +import 'dotenv/config' +import { db } from '~/data/db' +import { apiClients, blocks, brands, posts, projects } from '~/data/schema' +import { hashToken } from '~/lib/api-auth' + +const brandRows = [ + { slug: 'nyo', name: 'NYO', color: '#2b4a9b', sort: 1 }, + { slug: 'pocket-rocket', name: 'Pocket Rocket', color: '#c43a22', sort: 2 }, +] + +const projectRows = [ + { brand: 'pocket-rocket', slug: 'trakk', name: 'Trakk', color: '#2e7d5b', sort: 1 }, + { brand: 'nyo', slug: 'mta360', name: 'MTA360', color: '#2b4a9b', sort: 2 }, + { brand: 'nyo', slug: 'mta-telematik', name: 'MTA Telematik', color: '#b4761a', sort: 3 }, + { brand: 'pocket-rocket', slug: 'orbit', name: 'Orbit', color: '#6b4ba8', sort: 4 }, + { brand: 'nyo', slug: 'pulsar', name: 'Pulsar', color: '#a33b6a', sort: 5 }, +] + +const postRows = [ + { + project: 'trakk', number: 142, slug: 'regel-engine-bedingung-trifft-aktion', + title: 'Regel-Engine: Bedingung trifft Aktion', type: 'feature' as const, audience: 'customer' as const, + teaser: 'Tickets reagieren jetzt selbst. Du setzt eine Bedingung und hängst eine Aktion daran: Status ändern, zuweisen, Nachricht schicken.', + publishAt: new Date('2026-07-30T07:14:00Z'), author: 'Paul', + }, + { + project: 'mta360', number: 141, slug: 'spalten-per-rechtsklick-verwalten', + title: 'Spalten per Rechtsklick verwalten', type: 'improvement' as const, audience: 'customer' as const, + teaser: 'Rechtsklick auf eine Kopfzeile öffnet das Spaltenmenü: sortieren, anpinnen, ausblenden, Breite zurücksetzen.', + publishAt: new Date('2026-07-29T09:00:00Z'), author: 'Matthias', + }, + { + project: 'mta-telematik', number: 140, slug: 'zehntausend-tracker-auf-einer-karte', + title: '10.000 Tracker auf einer Karte', type: 'feature' as const, audience: 'customer' as const, + teaser: 'Positionen kommen gebündelt aus TimescaleDB, Geofences werden serverseitig geprüft.', + publishAt: new Date('2026-07-28T10:30:00Z'), author: 'Paul', + }, + { + project: 'trakk', number: 139, slug: 'sla-uhr-zaehlt-feiertage-nicht-mehr-mit', + title: 'SLA-Uhr zählt Feiertage nicht mehr mit', type: 'fix' as const, audience: 'customer' as const, + teaser: 'Reaktionszeiten liefen über Wochenenden und Feiertage weiter und lösten falsche Eskalationen aus.', + publishAt: new Date('2026-07-25T08:00:00Z'), author: 'Matthias', + }, + { + project: 'mta360', number: 138, slug: 'lokale-tabellen-konfigurationen-entfallen', + title: 'Lokale Tabellen-Konfigurationen werden nicht mehr gelesen', type: 'breaking' as const, audience: 'customer' as const, + teaser: 'Ansichten liegen jetzt vollständig in der Datenbank. Wer noch eine Konfiguration im Browser hatte, legt seine Ansicht einmal neu an.', + publishAt: new Date('2026-07-23T12:00:00Z'), author: 'Matthias', + }, + { + project: 'orbit', number: 137, slug: 'ein-eingang-fuer-alle-kommentare', + title: 'Ein Eingang für alle Kommentare', type: 'info' as const, audience: 'internal' as const, + teaser: 'Kommentare aus Instagram, LinkedIn und Facebook landen in einem Eingang, Antworten gehen von dort zurück.', + publishAt: new Date('2026-07-21T15:45:00Z'), author: 'Paul', + }, +] + +export async function seed(): Promise { + const brandIds = new Map() + const projectIds = new Map() + + for (const row of brandRows) { + const [brand] = await db.insert(brands).values(row) + .onConflictDoUpdate({ target: brands.slug, set: { name: row.name, color: row.color, sort: row.sort } }) + .returning() + brandIds.set(row.slug, brand!.id) + } + + for (const row of projectRows) { + const [project] = await db.insert(projects) + .values({ brandId: brandIds.get(row.brand)!, slug: row.slug, name: row.name, color: row.color, sort: row.sort }) + .onConflictDoUpdate({ target: projects.slug, set: { name: row.name, color: row.color, sort: row.sort } }) + .returning() + projectIds.set(row.slug, project!.id) + } + + for (const row of postRows) { + const [post] = await db.insert(posts) + .values({ + projectId: projectIds.get(row.project)!, + number: row.number, + slug: row.slug, + title: row.title, + teaser: row.teaser, + type: row.type, + audience: row.audience, + status: 'published', + publishAt: row.publishAt, + authorName: row.author, + }) + .onConflictDoUpdate({ target: [posts.projectId, posts.slug], set: { title: row.title, teaser: row.teaser } }) + .returning() + + await db.delete(blocks).where(eq(blocks.postId, post!.id)) + await db.insert(blocks).values({ postId: post!.id, sort: 0, type: 'text', data: { text: row.teaser } }) + } + + await db.insert(apiClients).values({ + name: 'Trakk Leseclient', + tokenHash: hashToken('lb_seed_trakk_read'), + mode: 'read', + scope: 'customer', + projectId: projectIds.get('trakk')!, + }).onConflictDoNothing() +} + +function eqPost(postId: string) { + return eq(blocks.postId, postId) +} +``` + +Am Dateianfang zusätzlich `import { eq } from 'drizzle-orm'` setzen. + +Ausführbar machen, am Dateiende: + +```ts +if (process.argv[1]?.endsWith('seed.ts')) { + await seed() + await pool.end() +} +``` + +Dafür `pool` mit importieren: `import { db, pool } from '~/data/db'`. + +- [x] **Step 4: Test laufen lassen und Seed ausführen** + +```bash +pnpm vitest run tests/scripts/seed.test.ts +pnpm db:seed +``` + +Erwartet: PASS, und in der Entwicklungsdatenbank stehen fünf Projekte und sechs Beiträge. + +- [x] **Step 5: Endpunkt von Hand prüfen** + +```bash +pnpm dev +curl -s -H "Authorization: Bearer lb_seed_trakk_read" "http://localhost:4700/api/v1/posts" | head -40 +curl -s -o /dev/null -w "%{http_code}\n" "http://localhost:4700/api/v1/posts" +``` + +Erwartet: erste Anfrage liefert die Trakk-Beiträge als JSON, zweite Anfrage antwortet 401. Der interne Orbit-Beitrag darf nicht enthalten sein. + +- [x] **Step 6: Gesamtlauf** + +```bash +pnpm test +pnpm typecheck +pnpm build +``` + +Erwartet: alles grün. + +--- + +## Selbstprüfung gegen die Spec + +| Spec-Abschnitt | Aufgabe in diesem Plan | +|---|---| +| Datenmodell brand, project | Task 2 | +| Datenmodell post, block, issue, tag, post_tag | Task 3 | +| Datenmodell media | Task 3, Upload erst Plan 5 | +| Datenmodell api_client, post_read | Task 6 | +| Datenmodell user, user_project_role | Plan 2, gehört zu Auth und Admin | +| Datenmodell reaction, delivery, audit_log | Plan 2 und Plan 4 | +| Sichtbarkeit nach Zielgruppe, serverseitig | Task 4, angewendet in Task 7 und 8 | +| Statusübergänge, Prüfungen mit Hinweis und Sperre | Task 5 | +| Lese-API inklusive unread und read | Task 8 und 9 | +| Schreib-API und Medien | Plan 5 | +| Feeds | Plan 3 | +| Admin, Editor, Import-Vorschläge | Plan 2 und Plan 5 | +| Web-Archiv, Routen, Suche | Plan 3 | +| Client-Pakete React und Vue | Plan 4 | +| Tokens, Hell und Dunkel | Task 1 legt die Tokens, Umschalter in Plan 3 | +| Tests auf Sichtbarkeit, Status, Rechte, API | Tasks 4, 5, 6, 8, 9 | +| Playwright-Smoketest | Plan 3, sobald es eine Oberfläche gibt | + +## Offene Punkte, die vor Plan 2 geklärt werden müssen + +- Subdomain für das Deployment +- Ob der Admin-Login über better-auth mit E-Mail und Passwort startet oder direkt auf Portal-SSO wartet diff --git a/docs/superpowers/specs/2026-07-30-logbuch-design.md b/docs/superpowers/specs/2026-07-30-logbuch-design.md new file mode 100644 index 0000000..1e5bde2 --- /dev/null +++ b/docs/superpowers/specs/2026-07-30-logbuch-design.md @@ -0,0 +1,322 @@ +# Logbuch - Design + +Stand: 30.07.2026 + +## Zweck + +Logbuch ist die zentrale Stelle, an der pro Projekt festgehalten wird, was passiert ist. Alle zwei Wochen picken sich Moderatoren die stärksten Neuerungen eines Projekts heraus, schreiben sie mit Screenshots auf und veröffentlichen sie. Die jeweilige Anwendung holt die Beiträge über eine API und zeigt sie ihren Nutzern als "Was ist neu" an. Zusätzlich gibt es ein Web-Archiv in Blog-Form: alle Projekte auf einer Übersicht, oder ein einzelnes Projekt mit Jahres- und Monatsarchiv. + +Der Aufbau ist durchgängig dynamisch. Marken, Projekte, Beitragstypen, Inhaltsblöcke und Kanäle sind Daten, nicht Code. Ein neues Projekt anzulegen ist eine Eingabe im Admin, kein Deployment. + +## Rahmenbedingungen und Entscheidungen + +| Punkt | Entscheidung | +|---|---| +| Reichweite | Zuerst intern, Datenmodell aber mit Marke, Projekt und Zielgruppe, damit Kundennutzung ohne Umbau möglich ist | +| Inhalte | Von Hand im Admin aus Blöcken gebaut, dazu Import-Vorschläge aus Trakk und GitHub als Rohmaterial | +| Kanäle Runde eins | Web-Archiv und In-App über API | +| Kanäle später | Mail über SES, Push über ntfy. Im Datenmodell vorgesehen, nicht gebaut | +| Deployment | Eigene App, eigenes Repo, eigene Subdomain, Coolify. Nicht Teil von Portal-v2 | +| Auth | better-auth für Moderatoren und Admins, Portal-SSO später. Basic-Auth-Gate vor der Instanz wie bei allen NYO-Apps | +| Medien | Coolify-Volume hinter einer Storage-Abstraktion, S3 später als Adapter | +| Web-Archiv Runde eins | Interne Ansicht hinter Basic-Auth. Kundensicht läuft ausschließlich über die App-API | +| Lesen | Bewusst offen. Wer durch das Basic-Auth-Gate kommt, darf alle Projekte und alle internen Beiträge lesen. Kein Leser-Konto, keine Leserechte-Pflege | +| Erfassen | Muss in unter zwei Minuten gehen, ohne Einarbeitung. Wenn Kollegen es als Aufwand empfinden, wird es nicht benutzt. Siehe Abschnitt "Erfassen ohne Reibung" | +| Erscheinungsbild | Wie ein guter Blog, nicht wie ein Verwaltungswerkzeug. Hell ist Standard, Dunkel vollständig unterstützt | + +### Ausdrücklich nicht im Umfang + +Mailversand, eigene Absenderdomains pro Kunde, Kampagnen-Automationen, A/B-Tests, Billing, Kommentarfunktion, öffentliche Anmeldeformulare, Double-Opt-in. + +## Firmen- und Projektstruktur + +Spaceport Adventures ist das Dach, darunter die Schwestern NYO, Pocket Rocket, Tajo und Aliens Exist. Marke ist deshalb eine eigene Ebene über dem Projekt, nicht ein Feld am Projekt. Die Startseite kann nach Marke gruppieren, ein Projekt gehört immer zu genau einer Marke. + +## Architektur + +### Stack + +Übernommen aus Trakk und Portal-v2, damit der Kosmos einheitlich bleibt: + +- Next.js 16.2 (App Router), React 19 +- PostgreSQL mit Drizzle ORM +- Tailwind 4 +- better-auth für Authentifizierung +- next-intl für Zweisprachigkeit (de, en) +- zod für Validierung an allen API-Grenzen +- react-icons mit Tabler-Icons +- overlayscrollbars statt native Scrollbars +- pnpm + +### Schichten + +Vier klar getrennte Einheiten, jede mit eigener Aufgabe und eigener Schnittstelle: + +1. **Domain** (`src/domain/`): Kernlogik ohne Framework-Bezug. Sichtbarkeitsregeln, Statusübergänge, Rechteprüfung, Slug-Erzeugung. Reine Funktionen, vollständig testbar ohne Datenbank. +2. **Data** (`src/data/`): Drizzle-Schema und Repositories. Jede Tabelle bekommt ein Repository mit benannten Abfragen. Kein SQL außerhalb dieser Schicht. +3. **API** (`src/app/api/v1/`): HTTP-Grenze. Nimmt Anfragen an, validiert per zod, prüft Token oder Session, ruft Domain und Data, gibt JSON zurück. Keine Logik. +4. **UI** (`src/app/(admin)/`, `src/app/(public)/`): Admin und Web-Archiv. Holt Daten über Server Components, schreibt über Server Actions oder die API. + +Die Sichtbarkeitsregel lebt ausschließlich in der Domain-Schicht und wird an genau einer Stelle angewendet, bevor Daten die API verlassen. Kein Frontend filtert Zielgruppen. + +## Datenmodell + +### brand + +Marke oder Firma. `id`, `slug`, `name`, `color`, `logo_media_id`, `sort`, `created_at`. + +### project + +`id`, `brand_id`, `slug`, `name`, `description`, `color`, `logo_media_id`, `is_active`, `sort`, `created_at`. + +Slug ist projektweit eindeutig und Teil der Web-Routen. + +### post + +Die zentrale Einheit. + +| Feld | Bedeutung | +|---|---| +| `id` | | +| `project_id` | Zugehöriges Projekt | +| `issue_id` | Optionale Ausgabe | +| `slug` | Eindeutig pro Projekt | +| `title`, `teaser` | | +| `type` | `feature`, `improvement`, `fix`, `breaking`, `info` | +| `audience` | `internal`, `customer`, `public` | +| `status` | `draft`, `review`, `scheduled`, `published`, `archived` | +| `locale` | `de` oder `en` | +| `translation_group` | Verbindet Übersetzungen desselben Beitrags. Beiträge können auch nur einsprachig existieren | +| `publish_at` | Termin bei `scheduled`, Veröffentlichungszeitpunkt bei `published` | +| `author_id` | Autor, auch ein API-Client möglich | +| `created_by_client_id` | Gesetzt, wenn der Beitrag über die API entstand | +| `cover_media_id` | Aufmacherbild | +| `search_vector` | tsvector für Volltextsuche | +| `created_at`, `updated_at` | | + +Die Zielgruppen sind aufsteigend offen: `public` sehen alle, `customer` sehen Kunden und intern, `internal` nur intern. Ein Kunde bekommt nie einen internen Beitrag ausgeliefert, auch nicht als Titel in einer Liste. + +### block + +Inhaltsblöcke eines Beitrags. `id`, `post_id`, `sort`, `type`, `data` (JSONB). + +Blocktypen in Runde eins: `text` (Rich Text), `image`, `gallery`, `before_after`, `video`, `quote`, `code`, `link`, `callout`. + +Jeder Typ hat ein zod-Schema in einer Registry und eine Render-Komponente. Ein neuer Typ heißt: Schema plus Komponente plus Editor-Eingabe. Kein Schema-Change an der Datenbank. + +### issue + +Optionale Klammer über mehrere Beiträge. `id`, `project_id`, `title`, `period_from`, `period_to`, `status`, `publish_at`, `created_at`. + +Eine Ausgabe erzwingt keinen Rhythmus. Beiträge können jederzeit einzeln erscheinen, oder gebündelt als Ausgabe alle zwei Wochen. + +### tag, post_tag + +Freie Schlagworte pro Projekt, Filter im Archiv. + +### media + +`id`, `project_id`, `kind` (`image`, `video`), `original_filename`, `mime`, `width`, `height`, `byte_size`, `variants` (JSONB mit Breite, Format und Pfad je Variante), `alt`, `caption`, `uploaded_by`, `created_at`. + +Bilder werden beim Upload nach WebP konvertiert und in mehreren Breiten abgelegt. Alt-Text wird angemahnt, ist aber nur bei öffentlichen Beiträgen Pflicht. + +### user, user_project_role + +`user` kommt von better-auth, ergänzt um `is_admin`. + +`user_project_role`: `user_id`, `project_id`, `role` (`moderator`). + +Ein Konto braucht nur, wer schreibt. Lesen ist offen und an keine Rolle gebunden. Schreibrechte gelten pro Projekt, ein Admin hat implizit alle Rechte in allen Projekten. + +Die Tabelle trägt eine Rollenspalte statt eines Kennzeichens, damit weitere Rollen später ohne Migration dazukommen können. + +### api_client + +`id`, `name`, `token_hash`, `project_id` (null bedeutet alle Projekte), `scopes`, `can_publish` (Standard false), `last_used_at`, `revoked_at`. + +Zwei Arten von Clients: **Write-Clients** legen Drafts an und laden Medien hoch. **Read-Clients** sind die eingebundenen Anwendungen und holen veröffentlichte Beiträge ihrer Zielgruppe. + +### post_read + +`project_id`, `external_user_id`, `post_id`, `read_at`. + +Der gelesen-Status hängt an der Nutzerkennung der Zielanwendung, nicht an einem Logbuch-Konto. Trakk schickt seine eigene User-ID mit, MTA360 seine. Die Kennung wird pro Projekt gespeichert, damit sich IDs verschiedener Anwendungen nicht überschneiden. + +### reaction + +`post_id`, `project_id`, `external_user_id`, `kind` (`useful`, `question`), `created_at`. Eindeutig pro Nutzer, Beitrag und Art. + +In Runde eins nur aus der App, weil das Web-Archiv keine Leser-Identität hat. + +### delivery + +`id`, `post_id`, `channel` (`web`, `inapp`, `mail`, `push`), `status`, `dispatched_at`, `error`. + +Web und In-App werden beim Veröffentlichen als erledigt vermerkt. Die Tabelle existiert, damit Mail und Push später nur einen Adapter brauchen. + +### audit_log + +`id`, `actor_type` (`user`, `client`), `actor_id`, `action`, `entity`, `entity_id`, `data`, `created_at`. + +Protokolliert mindestens: Veröffentlichen, Zurückziehen, Zielgruppenwechsel, Rechteänderung, Token-Erzeugung. + +## Rollen und Rechte + +| Rolle | Darf | +|---|---| +| Admin | Alles. Marken, Projekte, Nutzer, Tokens verwalten | +| Moderator (pro Projekt) | Beiträge und Ausgaben anlegen, bearbeiten, veröffentlichen, zurückziehen, Medien hochladen | +| Jeder im internen Archiv | Alle Projekte und alle Beiträge lesen, ohne Konto und ohne Rollenzuweisung | +| Write-Client | Drafts anlegen und bearbeiten, Medien hochladen. Nicht veröffentlichen | +| Read-Client | Veröffentlichte Beiträge der erlaubten Zielgruppe abrufen, gelesen melden | + +Lesen ist bewusst nicht eingeschränkt. Der Schutz liegt in Runde eins allein am Basic-Auth-Gate vor der Instanz. Das ist eine bewusste Entscheidung, damit niemand Leserechte pflegen muss, und gilt nur solange das Web-Archiv intern ist. Sobald Kunden eine Web-Ansicht bekommen sollen, muss diese Entscheidung neu getroffen werden. + +`can_publish` bleibt für Write-Clients auf false. Damit landet nichts unkontrolliert bei Kunden, auch nicht aus einem automatisierten Lauf. + +## API + +Version im Pfad, `/api/v1`. Alle Eingaben zod-validiert. Fehler einheitlich als Problem-JSON mit `type`, `title`, `status`, `detail`. + +### Lesend, Bearer eines Read-Clients + +| Route | Zweck | +|---|---| +| `GET /api/v1/brands` | Marken | +| `GET /api/v1/projects` | Projekte, gefiltert auf die Berechtigung des Clients | +| `GET /api/v1/posts` | Beiträge. Parameter: `project`, `since`, `type`, `tag`, `q`, `page`, `per_page` | +| `GET /api/v1/posts/:slug` | Einzelner Beitrag mit Blöcken und Medien | +| `GET /api/v1/issues` | Ausgaben eines Projekts | +| `GET /api/v1/unread` | Ungelesene Beiträge. Parameter: `external_user_id` | +| `POST /api/v1/read` | Meldet einen oder mehrere Beiträge als gelesen | +| `POST /api/v1/reactions` | Reaktion setzen oder entfernen | + +Die Zielgruppe wird nicht vom Aufrufer bestimmt, sondern aus dem Client abgeleitet. Ein Read-Client der MTA360-Kundenoberfläche bekommt niemals interne Beiträge, egal welche Parameter er schickt. + +### Schreibend, Bearer eines Write-Clients + +| Route | Zweck | +|---|---| +| `POST /api/v1/posts` | Beitrag anlegen, Status immer `draft` | +| `PATCH /api/v1/posts/:id` | Beitrag ändern, solange er nicht veröffentlicht ist | +| `PUT /api/v1/posts/:id/blocks` | Blöcke setzen, vollständige Liste in Reihenfolge | +| `POST /api/v1/media` | Datei hochladen, multipart. Liefert Media-ID und Varianten | + +`POST /api/v1/posts` akzeptiert einen `idempotency_key`, damit ein wiederholter Aufruf keinen Doppelbeitrag erzeugt. + +### Feeds + +`GET /feed/:project.xml` als Atom, nur Beiträge der Zielgruppe `public`. + +## Einbindung in die Anwendungen + +Keine iframes. Die Anwendungen holen JSON und rendern selbst, damit das Panel im jeweiligen Design sitzt. + +Dazu zwei dünne Client-Pakete im Repo: + +- `packages/client-react` für Trakk, Orbit, Arc und weitere Next-Apps +- `packages/client-vue` für MTA360 und nyo-frontend + +Beide bieten dasselbe: Beiträge laden, ungelesene Anzahl als Badge, Panel mit Beitragsliste und Detailansicht, gelesen melden, reagieren. Die Anwendung übergibt Read-Key, Projekt-Slug und ihre eigene Nutzerkennung. + +Der Read-Key gehört serverseitig in die einbindende App. Die Pakete rufen einen kleinen Proxy-Endpunkt der jeweiligen App auf, damit der Key nie im Browser liegt. + +## Admin + +- Projekt-Switcher in der Kopfzeile, Rechte bestimmen die Auswahl +- Beitragsliste mit Status, Typ, Zielgruppe, Termin, Filter und Suche +- Block-Editor: Blöcke hinzufügen, sortieren per Drag & Drop, Bilder direkt in den Editor ziehen +- Vorschau umschaltbar zwischen den Zielgruppen, damit vor dem Veröffentlichen sichtbar ist, was ein Kunde sieht +- Workflow `draft` nach `review` nach `published`, mit Zurückziehen nach `archived` +- Terminierung: Termin setzen, ein Cron veröffentlicht +- Ausgaben: Zeitraum wählen, Beiträge zuordnen, gemeinsam veröffentlichen +- Medienbibliothek pro Projekt mit Alt-Text-Pflege +- Verwaltung von Marken, Projekten, Nutzern, Rollen und Tokens für Admins + +## Erfassen ohne Reibung + +Das Projekt scheitert oder gelingt an dieser Stelle. Wenn ein Kollege für einen Beitrag über eine neue Funktion mehr als zwei Minuten braucht oder erst etwas lernen muss, schreibt er nichts. Deshalb sind das harte Anforderungen, keine Verbesserungen für später: + +- **Ein Feld zum Anfangen.** Neuer Beitrag heißt: Titel eingeben, losschreiben. Projekt ist aus dem Kontext vorbelegt, Typ ist `feature`, Zielgruppe ist `internal`, Sprache ist `de`. Alles änderbar, nichts abzufragen +- **Screenshot aus der Zwischenablage.** Bild mit Strg+V direkt in den Editor einfügen, ohne Datei-Dialog. Genauso Drag & Drop von mehreren Bildern auf einmal, die dann als Galerie landen +- **Schreiben wie in einem Textfeld.** Der Text-Block versteht Markdown-Eingaben beim Tippen (Überschrift, Liste, Fettung, Link). Wer Markdown nicht kennt, benutzt die Werkzeugleiste. Eingefügter Text aus anderen Quellen behält Struktur, verliert Fremdformatierung +- **Automatisches Speichern.** Entwürfe speichern sich selbst. Kein verlorener Text, keine Speicher-Frage beim Verlassen +- **Blöcke sind unsichtbar, bis man sie braucht.** Wer nur Text und Bilder schreibt, sieht keine Block-Verwaltung. Weitere Blocktypen kommen über einen Einfüge-Knopf oder `/` im Text +- **Veröffentlichen ist ein Knopf.** Kein Pflicht-Review, kein Pflicht-Termin, keine Pflicht-Schlagworte. Der Review-Status existiert für die, die ihn wollen +- **Nichts blockiert ohne Grund.** Prüfungen vor dem Veröffentlichen sind Hinweise, keine Sperren. Gesperrt wird nur, was echten Schaden anrichtet: fehlende Zielgruppe und öffentliche Beiträge ohne Alt-Text + +## Erscheinungsbild + +Es soll aussehen wie ein guter Blog, den man freiwillig liest, nicht wie eine Verwaltungsmaske. Große Aufmacherbilder, ruhige Typografie mit ordentlicher Zeilenlänge im Lesetext, klare Abstände, Beitragstypen als dezente Badges, Projektfarbe als Akzent statt als Anstrich. + +Harte Regeln: + +- **Keine nativen Scrollbars.** Überall overlayscrollbars, auch in Panels, Modalen, Listen, Medienbibliothek und im In-App-Panel. Keine Ausnahme +- **Kein großes Stylesheet.** Gestaltet wird mit Tailwind-Utilities und Design-Tokens. Das globale CSS enthält nur Tokens, Basis-Typografie und das overlayscrollbars-Thema und bleibt unter 150 Zeilen. Keine CSS-Datei pro Komponente, kein wachsendes Sammel-Stylesheet +- **Hell ist Standard, Dunkel ist vollwertig.** Umsetzung über CSS-Variablen als Tokens plus Tailwind-Dark-Variante. Beide Modi über dieselben Tokens, kein zweites Stylesheet, keine Sonderfälle je Komponente. Umschalter im Kopfbereich, Auswahl wird gespeichert, Systemeinstellung als dritte Option +- **Keine Emojis** in Oberfläche und Inhalten +- **Keine Pixelangaben**, Abstände und Größen über die Tailwind-Skala +- Icons ausschließlich Tabler über react-icons + +### Import-Vorschläge + +Ein Panel im Beitrags-Editor zieht Rohmaterial für einen Zeitraum: + +- Erledigte Tickets aus der Trakk-API des zugeordneten Projekts +- Releases und zusammengeführte Pull Requests aus GitHub + +Ergebnis ist eine Auswahlliste. Angehakte Einträge werden als Blöcke mit Titel und Link eingefügt. Kein generierter Text. Die Zuordnung Projekt zu Trakk-Projekt und GitHub-Repository steht am Projekt. + +## Web-Archiv + +| Route | Inhalt | +|---|---| +| `/` | Übersicht aller Projekte, gruppiert nach Marke, neueste Beiträge | +| `/[project]` | Projekt-Blog mit Seitenweiterschaltung | +| `/[project]/[year]` und `/[project]/[year]/[month]` | Zeitarchiv | +| `/[project]/[slug]` | Einzelner Beitrag | +| `/[project]/tag/[tag]` | Schlagwort-Filter | +| `/issues/[id]` | Ausgabe als Ganzes | +| `/search` | Volltextsuche über Postgres tsvector | + +Zweisprachig über next-intl, keine festverdrahteten Texte. Beitragstypen als Badges. OG-Bilder werden aus Projektfarbe, Logo und Titel erzeugt. Bilder als WebP mit `srcset`, Galerie mit Lightbox. + +## Fehlerbehandlung + +- Eingaben scheitern laut und früh: zod an jeder API-Grenze, Antwort mit Feldfehlern +- Veröffentlichen prüft vorab und unterscheidet Hinweis von Sperre. Gesperrt wird nur bei fehlender Zielgruppe, fehlendem Titel, leerem Beitrag oder öffentlichem Beitrag ohne Alt-Text. Fehlender Alt-Text bei internen Beiträgen, fehlende Schlagworte oder fehlendes Aufmacherbild sind Hinweise und halten niemanden auf +- Medien-Upload ist zweistufig: Datei speichern, dann Varianten erzeugen. Scheitert die Konvertierung, bleibt das Original nutzbar und der Fehler steht am Medium +- Der Veröffentlichungs-Cron ist wiederholbar: er nimmt Beiträge mit `status = scheduled` und `publish_at <= jetzt`, ein Doppellauf erzeugt keine Doppelauslieferung, weil `delivery` den Zustand hält +- Ausfälle von Trakk oder GitHub beim Import blockieren den Editor nicht, das Panel zeigt den Fehler und bleibt schließbar +- Unbekannte Blocktypen im Renderer werden übersprungen und protokolliert, statt die Seite zu zerlegen + +## Tests + +Der Schwerpunkt liegt dort, wo ein Fehler weh tut. + +| Bereich | Art | +|---|---| +| Sichtbarkeitsregel (Zielgruppe je Read-Client) | Vitest, vollständige Matrix | +| Statusübergänge und Veröffentlichungs-Prüfungen | Vitest | +| Schreibrechte pro Projekt, Write-Client darf nicht veröffentlichen | Vitest | +| Block-Schemas | Vitest gegen die Registry | +| API-Routen | Vitest mit Testdatenbank, Schwerpunkt Auth und Filter | +| Draft bis Veröffentlicht bis In-App-Abruf | Ein Playwright-Smoketest | + +Die Sichtbarkeitsmatrix bekommt bewusst mehr Tests als der Rest, weil ein Fehler dort interne Inhalte an Kunden ausliefert. + +## Betrieb + +- Eigenes Repository, Verzeichnis `projects/logbuch` +- Coolify-Deployment, eigene Subdomain, Basic-Auth-Gate davor +- Eigene PostgreSQL-Instanz +- Migrationen über Drizzle, beim Deployment ausgeführt +- Medien auf einem Coolify-Volume, Zugriff über eine Storage-Schnittstelle mit einem lokalen Adapter. Ein S3-Adapter kann später ohne Änderung am Rest ergänzt werden +- Cron für terminierte Veröffentlichung +- Rybbit für Zugriffszahlen des Web-Archivs + +## Offene Punkte + +- Subdomain noch nicht festgelegt +- Welche Nutzerkennung MTA360 und Trakk für den gelesen-Status liefern, muss beim Einbinden je App geklärt werden +- Ob Tajo und Aliens Exist eigene Projekte in Logbuch bekommen, ist offen. Das Datenmodell trägt sie diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..7e417b6 --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'drizzle-kit' +import 'dotenv/config' + +export default defineConfig({ + dialect: 'postgresql', + schema: './src/data/schema/index.ts', + out: './drizzle', + dbCredentials: { url: process.env.DATABASE_URL! }, +}) diff --git a/drizzle/0000_tiny_madrox.sql b/drizzle/0000_tiny_madrox.sql new file mode 100644 index 0000000..d3a0596 --- /dev/null +++ b/drizzle/0000_tiny_madrox.sql @@ -0,0 +1,24 @@ +CREATE TABLE "brand" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "slug" text NOT NULL, + "name" text NOT NULL, + "color" text DEFAULT '#191c20' NOT NULL, + "sort" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "brand_slug_unique" UNIQUE("slug") +); +--> statement-breakpoint +CREATE TABLE "project" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "brand_id" uuid NOT NULL, + "slug" text NOT NULL, + "name" text NOT NULL, + "description" text, + "color" text DEFAULT '#191c20' NOT NULL, + "is_active" boolean DEFAULT true NOT NULL, + "sort" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "project_slug_unique" UNIQUE("slug") +); +--> statement-breakpoint +ALTER TABLE "project" ADD CONSTRAINT "project_brand_id_brand_id_fk" FOREIGN KEY ("brand_id") REFERENCES "public"."brand"("id") ON DELETE restrict ON UPDATE no action; \ No newline at end of file diff --git a/drizzle/0001_serious_echo.sql b/drizzle/0001_serious_echo.sql new file mode 100644 index 0000000..26d7de5 --- /dev/null +++ b/drizzle/0001_serious_echo.sql @@ -0,0 +1,88 @@ +CREATE TYPE "public"."media_kind" AS ENUM('image', 'video');--> statement-breakpoint +CREATE TYPE "public"."issue_status" AS ENUM('draft', 'scheduled', 'published');--> statement-breakpoint +CREATE TYPE "public"."post_audience" AS ENUM('internal', 'customer', 'public');--> statement-breakpoint +CREATE TYPE "public"."post_locale" AS ENUM('de', 'en');--> statement-breakpoint +CREATE TYPE "public"."post_status" AS ENUM('draft', 'review', 'scheduled', 'published', 'archived');--> statement-breakpoint +CREATE TYPE "public"."post_type" AS ENUM('feature', 'improvement', 'fix', 'breaking', 'info');--> statement-breakpoint +CREATE TABLE "media" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "project_id" uuid NOT NULL, + "kind" "media_kind" DEFAULT 'image' NOT NULL, + "original_filename" text NOT NULL, + "mime" text NOT NULL, + "width" integer, + "height" integer, + "byte_size" integer NOT NULL, + "variants" jsonb DEFAULT '[]'::jsonb NOT NULL, + "alt" text, + "caption" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "block" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "post_id" uuid NOT NULL, + "sort" integer DEFAULT 0 NOT NULL, + "type" text NOT NULL, + "data" jsonb DEFAULT '{}'::jsonb NOT NULL +); +--> statement-breakpoint +CREATE TABLE "issue" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "project_id" uuid NOT NULL, + "title" text NOT NULL, + "period_from" timestamp with time zone, + "period_to" timestamp with time zone, + "status" "issue_status" DEFAULT 'draft' NOT NULL, + "publish_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "post_tag" ( + "post_id" uuid NOT NULL, + "tag_id" uuid NOT NULL, + CONSTRAINT "post_tag_post_id_tag_id_pk" PRIMARY KEY("post_id","tag_id") +); +--> statement-breakpoint +CREATE TABLE "post" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "project_id" uuid NOT NULL, + "issue_id" uuid, + "number" integer NOT NULL, + "slug" text NOT NULL, + "title" text NOT NULL, + "teaser" text, + "type" "post_type" DEFAULT 'feature' NOT NULL, + "audience" "post_audience" DEFAULT 'internal' NOT NULL, + "status" "post_status" DEFAULT 'draft' NOT NULL, + "locale" "post_locale" DEFAULT 'de' NOT NULL, + "translation_group" uuid, + "publish_at" timestamp with time zone, + "author_name" text, + "cover_media_id" uuid, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "post_project_slug" UNIQUE("project_id","slug"), + CONSTRAINT "post_project_number" UNIQUE("project_id","number") +); +--> statement-breakpoint +CREATE TABLE "tag" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "project_id" uuid NOT NULL, + "slug" text NOT NULL, + "label" text NOT NULL, + CONSTRAINT "tag_project_slug" UNIQUE("project_id","slug") +); +--> statement-breakpoint +ALTER TABLE "media" ADD CONSTRAINT "media_project_id_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."project"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "block" ADD CONSTRAINT "block_post_id_post_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."post"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "issue" ADD CONSTRAINT "issue_project_id_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."project"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "post_tag" ADD CONSTRAINT "post_tag_post_id_post_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."post"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "post_tag" ADD CONSTRAINT "post_tag_tag_id_tag_id_fk" FOREIGN KEY ("tag_id") REFERENCES "public"."tag"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "post" ADD CONSTRAINT "post_project_id_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."project"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "post" ADD CONSTRAINT "post_issue_id_issue_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issue"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "post" ADD CONSTRAINT "post_cover_media_id_media_id_fk" FOREIGN KEY ("cover_media_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "tag" ADD CONSTRAINT "tag_project_id_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."project"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "block_post_sort" ON "block" USING btree ("post_id","sort");--> statement-breakpoint +CREATE INDEX "post_publish_at" ON "post" USING btree ("publish_at");--> statement-breakpoint +CREATE INDEX "post_project_status" ON "post" USING btree ("project_id","status"); \ No newline at end of file diff --git a/drizzle/0002_glamorous_wild_child.sql b/drizzle/0002_glamorous_wild_child.sql new file mode 100644 index 0000000..217127d --- /dev/null +++ b/drizzle/0002_glamorous_wild_child.sql @@ -0,0 +1,28 @@ +CREATE TYPE "public"."client_mode" AS ENUM('read', 'write');--> statement-breakpoint +CREATE TYPE "public"."client_scope" AS ENUM('internal', 'customer', 'public');--> statement-breakpoint +CREATE TABLE "api_client" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" text NOT NULL, + "token_hash" text NOT NULL, + "mode" "client_mode" DEFAULT 'read' NOT NULL, + "scope" "client_scope" DEFAULT 'customer' NOT NULL, + "project_id" uuid, + "can_publish" boolean DEFAULT false NOT NULL, + "last_used_at" timestamp with time zone, + "revoked_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "api_client_token_hash_unique" UNIQUE("token_hash") +); +--> statement-breakpoint +CREATE TABLE "post_read" ( + "post_id" uuid NOT NULL, + "project_id" uuid NOT NULL, + "external_user_id" text NOT NULL, + "read_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "post_read_post_id_external_user_id_pk" PRIMARY KEY("post_id","external_user_id") +); +--> statement-breakpoint +ALTER TABLE "api_client" ADD CONSTRAINT "api_client_project_id_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."project"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "post_read" ADD CONSTRAINT "post_read_post_id_post_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."post"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "post_read" ADD CONSTRAINT "post_read_project_id_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."project"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "post_read_project_user" ON "post_read" USING btree ("project_id","external_user_id"); \ No newline at end of file diff --git a/drizzle/0003_acoustic_sabra.sql b/drizzle/0003_acoustic_sabra.sql new file mode 100644 index 0000000..2c93da9 --- /dev/null +++ b/drizzle/0003_acoustic_sabra.sql @@ -0,0 +1 @@ +ALTER TABLE "project" ADD COLUMN "code" text; \ No newline at end of file diff --git a/drizzle/0004_white_groot.sql b/drizzle/0004_white_groot.sql new file mode 100644 index 0000000..3e52558 --- /dev/null +++ b/drizzle/0004_white_groot.sql @@ -0,0 +1,66 @@ +CREATE TYPE "public"."project_role" AS ENUM('moderator');--> statement-breakpoint +CREATE TABLE "account" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "account_id" text NOT NULL, + "provider_id" text NOT NULL, + "access_token" text, + "refresh_token" text, + "access_token_expires_at" timestamp with time zone, + "refresh_token_expires_at" timestamp with time zone, + "scope" text, + "id_token" text, + "password" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "session" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "token" text NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "ip_address" text, + "user_agent" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "session_token_unique" UNIQUE("token") +); +--> statement-breakpoint +CREATE TABLE "user_project_role" ( + "user_id" text NOT NULL, + "project_id" uuid NOT NULL, + "role" "project_role" DEFAULT 'moderator' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "user_project_role_user_id_project_id_pk" PRIMARY KEY("user_id","project_id") +); +--> statement-breakpoint +CREATE TABLE "user" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "email" text NOT NULL, + "email_verified" boolean DEFAULT false NOT NULL, + "image" text, + "is_admin" boolean DEFAULT false NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "user_email_unique" UNIQUE("email") +); +--> statement-breakpoint +CREATE TABLE "verification" ( + "id" text PRIMARY KEY NOT NULL, + "identifier" text NOT NULL, + "value" text NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user_project_role" ADD CONSTRAINT "user_project_role_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user_project_role" ADD CONSTRAINT "user_project_role_project_id_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."project"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "account_user" ON "account" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "session_user" ON "session" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "user_project_role_project" ON "user_project_role" USING btree ("project_id");--> statement-breakpoint +CREATE INDEX "verification_identifier" ON "verification" USING btree ("identifier"); \ No newline at end of file diff --git a/drizzle/0005_eager_magdalene.sql b/drizzle/0005_eager_magdalene.sql new file mode 100644 index 0000000..646a7e1 --- /dev/null +++ b/drizzle/0005_eager_magdalene.sql @@ -0,0 +1,15 @@ +CREATE TYPE "public"."audit_actor_type" AS ENUM('user', 'client');--> statement-breakpoint +CREATE TABLE "audit_log" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "actor_type" "audit_actor_type" DEFAULT 'user' NOT NULL, + "actor_id" text, + "actor_label" text, + "action" text NOT NULL, + "entity" text NOT NULL, + "entity_id" text, + "data" jsonb DEFAULT '{}'::jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE INDEX "audit_log_created" ON "audit_log" USING btree ("created_at");--> statement-breakpoint +CREATE INDEX "audit_log_entity" ON "audit_log" USING btree ("entity","entity_id"); \ No newline at end of file diff --git a/drizzle/0006_stiff_living_tribunal.sql b/drizzle/0006_stiff_living_tribunal.sql new file mode 100644 index 0000000..a562fcf --- /dev/null +++ b/drizzle/0006_stiff_living_tribunal.sql @@ -0,0 +1,5 @@ +ALTER TABLE "media" ADD COLUMN "path" text NOT NULL;--> statement-breakpoint +ALTER TABLE "media" ADD COLUMN "variant_error" text;--> statement-breakpoint +ALTER TABLE "media" ADD COLUMN "uploaded_by" text;--> statement-breakpoint +ALTER TABLE "media" ADD CONSTRAINT "media_uploaded_by_user_id_fk" FOREIGN KEY ("uploaded_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "media_project_created" ON "media" USING btree ("project_id","created_at"); \ No newline at end of file diff --git a/drizzle/0007_parallel_doctor_doom.sql b/drizzle/0007_parallel_doctor_doom.sql new file mode 100644 index 0000000..2c79564 --- /dev/null +++ b/drizzle/0007_parallel_doctor_doom.sql @@ -0,0 +1,6 @@ +CREATE TABLE "post_counter" ( + "project_id" uuid PRIMARY KEY NOT NULL, + "value" integer DEFAULT 0 NOT NULL +); +--> statement-breakpoint +ALTER TABLE "post_counter" ADD CONSTRAINT "post_counter_project_id_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."project"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/drizzle/0008_true_spot.sql b/drizzle/0008_true_spot.sql new file mode 100644 index 0000000..438ec49 --- /dev/null +++ b/drizzle/0008_true_spot.sql @@ -0,0 +1,38 @@ +ALTER TYPE "public"."post_type" RENAME TO "post_type_legacy";--> statement-breakpoint +CREATE TABLE "post_type" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "key" text NOT NULL, + "label_de" text NOT NULL, + "label_en" text NOT NULL, + "color" text DEFAULT '#191c20' NOT NULL, + "sort" integer DEFAULT 0 NOT NULL, + "is_active" boolean DEFAULT true NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "post_type_key_unique" UNIQUE("key") +); +--> statement-breakpoint +INSERT INTO "post_type" ("key", "label_de", "label_en", "color", "sort") VALUES + ('feature', 'Feature', 'Feature', '#2b4a9b', 1), + ('improvement', 'Verbesserung', 'Improvement', '#2e7d5b', 2), + ('fix', 'Fix', 'Fix', '#b4761a', 3), + ('breaking', 'Breaking', 'Breaking', '#c43a22', 4), + ('info', 'Info', 'Info', '#4a5560', 5); +--> statement-breakpoint +CREATE OR REPLACE FUNCTION post_type_default() RETURNS uuid LANGUAGE sql STABLE AS $$ + SELECT "id" FROM "post_type" ORDER BY "is_active" DESC, "sort", "key" LIMIT 1 +$$; +--> statement-breakpoint +CREATE TABLE "api_idempotency_key" ( + "client_id" uuid NOT NULL, + "key" text NOT NULL, + "post_id" uuid NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "api_idempotency_key_client_id_key_pk" PRIMARY KEY("client_id","key") +); +--> statement-breakpoint +ALTER TABLE "post" ADD COLUMN "type_id" uuid DEFAULT post_type_default() NOT NULL;--> statement-breakpoint +UPDATE "post" SET "type_id" = "post_type"."id" FROM "post_type" WHERE "post_type"."key" = "post"."type"::text;--> statement-breakpoint +ALTER TABLE "api_idempotency_key" ADD CONSTRAINT "api_idempotency_key_client_id_api_client_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."api_client"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "api_idempotency_key" ADD CONSTRAINT "api_idempotency_key_post_id_post_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."post"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "post_type_sort" ON "post_type" USING btree ("sort","key");--> statement-breakpoint +ALTER TABLE "post" ADD CONSTRAINT "post_type_id_post_type_id_fk" FOREIGN KEY ("type_id") REFERENCES "public"."post_type"("id") ON DELETE restrict ON UPDATE no action; diff --git a/drizzle/0009_clean_mac_gargan.sql b/drizzle/0009_clean_mac_gargan.sql new file mode 100644 index 0000000..a4361a1 --- /dev/null +++ b/drizzle/0009_clean_mac_gargan.sql @@ -0,0 +1,2 @@ +ALTER TABLE "post" DROP COLUMN "type";--> statement-breakpoint +DROP TYPE "public"."post_type_legacy"; diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..a6404a7 --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -0,0 +1,174 @@ +{ + "id": "4fd0842f-19f7-4076-a83a-4bf4b670b345", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.brand": { + "name": "brand", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#191c20'" + }, + "sort": { + "name": "sort", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "brand_slug_unique": { + "name": "brand_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project": { + "name": "project", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "brand_id": { + "name": "brand_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#191c20'" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sort": { + "name": "sort", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "project_brand_id_brand_id_fk": { + "name": "project_brand_id_brand_id_fk", + "tableFrom": "project", + "tableTo": "brand", + "columnsFrom": [ + "brand_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_slug_unique": { + "name": "project_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..dc9b56d --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,837 @@ +{ + "id": "45004fc9-b81a-44cf-926b-b1b9beff3fab", + "prevId": "4fd0842f-19f7-4076-a83a-4bf4b670b345", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.brand": { + "name": "brand", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#191c20'" + }, + "sort": { + "name": "sort", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "brand_slug_unique": { + "name": "brand_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project": { + "name": "project", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "brand_id": { + "name": "brand_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#191c20'" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sort": { + "name": "sort", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "project_brand_id_brand_id_fk": { + "name": "project_brand_id_brand_id_fk", + "tableFrom": "project", + "tableTo": "brand", + "columnsFrom": [ + "brand_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_slug_unique": { + "name": "project_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media": { + "name": "media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "media_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'image'" + }, + "original_filename": { + "name": "original_filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime": { + "name": "mime", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "variants": { + "name": "variants", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "alt": { + "name": "alt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "caption": { + "name": "caption", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "media_project_id_project_id_fk": { + "name": "media_project_id_project_id_fk", + "tableFrom": "media", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.block": { + "name": "block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sort": { + "name": "sort", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "block_post_sort": { + "name": "block_post_sort", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "block_post_id_post_id_fk": { + "name": "block_post_id_post_id_fk", + "tableFrom": "block", + "tableTo": "post", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue": { + "name": "issue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_from": { + "name": "period_from", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "period_to": { + "name": "period_to", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "issue_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "publish_at": { + "name": "publish_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "issue_project_id_project_id_fk": { + "name": "issue_project_id_project_id_fk", + "tableFrom": "issue", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_tag": { + "name": "post_tag", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "post_tag_post_id_post_id_fk": { + "name": "post_tag_post_id_post_id_fk", + "tableFrom": "post_tag", + "tableTo": "post", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_tag_tag_id_tag_id_fk": { + "name": "post_tag_tag_id_tag_id_fk", + "tableFrom": "post_tag", + "tableTo": "tag", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "post_tag_post_id_tag_id_pk": { + "name": "post_tag_post_id_tag_id_pk", + "columns": [ + "post_id", + "tag_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post": { + "name": "post", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "teaser": { + "name": "teaser", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "post_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'feature'" + }, + "audience": { + "name": "audience", + "type": "post_audience", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "status": { + "name": "status", + "type": "post_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "locale": { + "name": "locale", + "type": "post_locale", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'de'" + }, + "translation_group": { + "name": "translation_group", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "publish_at": { + "name": "publish_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_media_id": { + "name": "cover_media_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "post_publish_at": { + "name": "post_publish_at", + "columns": [ + { + "expression": "publish_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_project_status": { + "name": "post_project_status", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "post_project_id_project_id_fk": { + "name": "post_project_id_project_id_fk", + "tableFrom": "post", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_issue_id_issue_id_fk": { + "name": "post_issue_id_issue_id_fk", + "tableFrom": "post", + "tableTo": "issue", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "post_cover_media_id_media_id_fk": { + "name": "post_cover_media_id_media_id_fk", + "tableFrom": "post", + "tableTo": "media", + "columnsFrom": [ + "cover_media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "post_project_slug": { + "name": "post_project_slug", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "slug" + ] + }, + "post_project_number": { + "name": "post_project_number", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tag": { + "name": "tag", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "tag_project_id_project_id_fk": { + "name": "tag_project_id_project_id_fk", + "tableFrom": "tag", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tag_project_slug": { + "name": "tag_project_slug", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.media_kind": { + "name": "media_kind", + "schema": "public", + "values": [ + "image", + "video" + ] + }, + "public.issue_status": { + "name": "issue_status", + "schema": "public", + "values": [ + "draft", + "scheduled", + "published" + ] + }, + "public.post_audience": { + "name": "post_audience", + "schema": "public", + "values": [ + "internal", + "customer", + "public" + ] + }, + "public.post_locale": { + "name": "post_locale", + "schema": "public", + "values": [ + "de", + "en" + ] + }, + "public.post_status": { + "name": "post_status", + "schema": "public", + "values": [ + "draft", + "review", + "scheduled", + "published", + "archived" + ] + }, + "public.post_type": { + "name": "post_type", + "schema": "public", + "values": [ + "feature", + "improvement", + "fix", + "breaking", + "info" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..6c3c03c --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,1051 @@ +{ + "id": "2acce0ea-fb01-4805-80fd-70adc031de0e", + "prevId": "45004fc9-b81a-44cf-926b-b1b9beff3fab", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.brand": { + "name": "brand", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#191c20'" + }, + "sort": { + "name": "sort", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "brand_slug_unique": { + "name": "brand_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project": { + "name": "project", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "brand_id": { + "name": "brand_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#191c20'" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sort": { + "name": "sort", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "project_brand_id_brand_id_fk": { + "name": "project_brand_id_brand_id_fk", + "tableFrom": "project", + "tableTo": "brand", + "columnsFrom": [ + "brand_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_slug_unique": { + "name": "project_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media": { + "name": "media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "media_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'image'" + }, + "original_filename": { + "name": "original_filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime": { + "name": "mime", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "variants": { + "name": "variants", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "alt": { + "name": "alt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "caption": { + "name": "caption", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "media_project_id_project_id_fk": { + "name": "media_project_id_project_id_fk", + "tableFrom": "media", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.block": { + "name": "block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sort": { + "name": "sort", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "block_post_sort": { + "name": "block_post_sort", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "block_post_id_post_id_fk": { + "name": "block_post_id_post_id_fk", + "tableFrom": "block", + "tableTo": "post", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue": { + "name": "issue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_from": { + "name": "period_from", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "period_to": { + "name": "period_to", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "issue_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "publish_at": { + "name": "publish_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "issue_project_id_project_id_fk": { + "name": "issue_project_id_project_id_fk", + "tableFrom": "issue", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_tag": { + "name": "post_tag", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "post_tag_post_id_post_id_fk": { + "name": "post_tag_post_id_post_id_fk", + "tableFrom": "post_tag", + "tableTo": "post", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_tag_tag_id_tag_id_fk": { + "name": "post_tag_tag_id_tag_id_fk", + "tableFrom": "post_tag", + "tableTo": "tag", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "post_tag_post_id_tag_id_pk": { + "name": "post_tag_post_id_tag_id_pk", + "columns": [ + "post_id", + "tag_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post": { + "name": "post", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "teaser": { + "name": "teaser", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "post_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'feature'" + }, + "audience": { + "name": "audience", + "type": "post_audience", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "status": { + "name": "status", + "type": "post_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "locale": { + "name": "locale", + "type": "post_locale", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'de'" + }, + "translation_group": { + "name": "translation_group", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "publish_at": { + "name": "publish_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_media_id": { + "name": "cover_media_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "post_publish_at": { + "name": "post_publish_at", + "columns": [ + { + "expression": "publish_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_project_status": { + "name": "post_project_status", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "post_project_id_project_id_fk": { + "name": "post_project_id_project_id_fk", + "tableFrom": "post", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_issue_id_issue_id_fk": { + "name": "post_issue_id_issue_id_fk", + "tableFrom": "post", + "tableTo": "issue", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "post_cover_media_id_media_id_fk": { + "name": "post_cover_media_id_media_id_fk", + "tableFrom": "post", + "tableTo": "media", + "columnsFrom": [ + "cover_media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "post_project_slug": { + "name": "post_project_slug", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "slug" + ] + }, + "post_project_number": { + "name": "post_project_number", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tag": { + "name": "tag", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "tag_project_id_project_id_fk": { + "name": "tag_project_id_project_id_fk", + "tableFrom": "tag", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tag_project_slug": { + "name": "tag_project_slug", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_client": { + "name": "api_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "client_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'read'" + }, + "scope": { + "name": "scope", + "type": "client_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'customer'" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "can_publish": { + "name": "can_publish", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "api_client_project_id_project_id_fk": { + "name": "api_client_project_id_project_id_fk", + "tableFrom": "api_client", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_client_token_hash_unique": { + "name": "api_client_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_read": { + "name": "post_read", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "external_user_id": { + "name": "external_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "post_read_project_user": { + "name": "post_read_project_user", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "post_read_post_id_post_id_fk": { + "name": "post_read_post_id_post_id_fk", + "tableFrom": "post_read", + "tableTo": "post", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_read_project_id_project_id_fk": { + "name": "post_read_project_id_project_id_fk", + "tableFrom": "post_read", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "post_read_post_id_external_user_id_pk": { + "name": "post_read_post_id_external_user_id_pk", + "columns": [ + "post_id", + "external_user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.media_kind": { + "name": "media_kind", + "schema": "public", + "values": [ + "image", + "video" + ] + }, + "public.issue_status": { + "name": "issue_status", + "schema": "public", + "values": [ + "draft", + "scheduled", + "published" + ] + }, + "public.post_audience": { + "name": "post_audience", + "schema": "public", + "values": [ + "internal", + "customer", + "public" + ] + }, + "public.post_locale": { + "name": "post_locale", + "schema": "public", + "values": [ + "de", + "en" + ] + }, + "public.post_status": { + "name": "post_status", + "schema": "public", + "values": [ + "draft", + "review", + "scheduled", + "published", + "archived" + ] + }, + "public.post_type": { + "name": "post_type", + "schema": "public", + "values": [ + "feature", + "improvement", + "fix", + "breaking", + "info" + ] + }, + "public.client_mode": { + "name": "client_mode", + "schema": "public", + "values": [ + "read", + "write" + ] + }, + "public.client_scope": { + "name": "client_scope", + "schema": "public", + "values": [ + "internal", + "customer", + "public" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..6cc85d4 --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -0,0 +1,1057 @@ +{ + "id": "5058fde9-f0f9-4300-b114-85c278913f85", + "prevId": "2acce0ea-fb01-4805-80fd-70adc031de0e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.brand": { + "name": "brand", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#191c20'" + }, + "sort": { + "name": "sort", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "brand_slug_unique": { + "name": "brand_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project": { + "name": "project", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "brand_id": { + "name": "brand_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#191c20'" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sort": { + "name": "sort", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "project_brand_id_brand_id_fk": { + "name": "project_brand_id_brand_id_fk", + "tableFrom": "project", + "tableTo": "brand", + "columnsFrom": [ + "brand_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_slug_unique": { + "name": "project_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media": { + "name": "media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "media_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'image'" + }, + "original_filename": { + "name": "original_filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime": { + "name": "mime", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "variants": { + "name": "variants", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "alt": { + "name": "alt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "caption": { + "name": "caption", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "media_project_id_project_id_fk": { + "name": "media_project_id_project_id_fk", + "tableFrom": "media", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.block": { + "name": "block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sort": { + "name": "sort", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "block_post_sort": { + "name": "block_post_sort", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "block_post_id_post_id_fk": { + "name": "block_post_id_post_id_fk", + "tableFrom": "block", + "tableTo": "post", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue": { + "name": "issue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_from": { + "name": "period_from", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "period_to": { + "name": "period_to", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "issue_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "publish_at": { + "name": "publish_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "issue_project_id_project_id_fk": { + "name": "issue_project_id_project_id_fk", + "tableFrom": "issue", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_tag": { + "name": "post_tag", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "post_tag_post_id_post_id_fk": { + "name": "post_tag_post_id_post_id_fk", + "tableFrom": "post_tag", + "tableTo": "post", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_tag_tag_id_tag_id_fk": { + "name": "post_tag_tag_id_tag_id_fk", + "tableFrom": "post_tag", + "tableTo": "tag", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "post_tag_post_id_tag_id_pk": { + "name": "post_tag_post_id_tag_id_pk", + "columns": [ + "post_id", + "tag_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post": { + "name": "post", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "teaser": { + "name": "teaser", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "post_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'feature'" + }, + "audience": { + "name": "audience", + "type": "post_audience", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "status": { + "name": "status", + "type": "post_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "locale": { + "name": "locale", + "type": "post_locale", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'de'" + }, + "translation_group": { + "name": "translation_group", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "publish_at": { + "name": "publish_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_media_id": { + "name": "cover_media_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "post_publish_at": { + "name": "post_publish_at", + "columns": [ + { + "expression": "publish_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_project_status": { + "name": "post_project_status", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "post_project_id_project_id_fk": { + "name": "post_project_id_project_id_fk", + "tableFrom": "post", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_issue_id_issue_id_fk": { + "name": "post_issue_id_issue_id_fk", + "tableFrom": "post", + "tableTo": "issue", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "post_cover_media_id_media_id_fk": { + "name": "post_cover_media_id_media_id_fk", + "tableFrom": "post", + "tableTo": "media", + "columnsFrom": [ + "cover_media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "post_project_slug": { + "name": "post_project_slug", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "slug" + ] + }, + "post_project_number": { + "name": "post_project_number", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tag": { + "name": "tag", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "tag_project_id_project_id_fk": { + "name": "tag_project_id_project_id_fk", + "tableFrom": "tag", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tag_project_slug": { + "name": "tag_project_slug", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_client": { + "name": "api_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "client_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'read'" + }, + "scope": { + "name": "scope", + "type": "client_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'customer'" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "can_publish": { + "name": "can_publish", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "api_client_project_id_project_id_fk": { + "name": "api_client_project_id_project_id_fk", + "tableFrom": "api_client", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_client_token_hash_unique": { + "name": "api_client_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_read": { + "name": "post_read", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "external_user_id": { + "name": "external_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "post_read_project_user": { + "name": "post_read_project_user", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "post_read_post_id_post_id_fk": { + "name": "post_read_post_id_post_id_fk", + "tableFrom": "post_read", + "tableTo": "post", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_read_project_id_project_id_fk": { + "name": "post_read_project_id_project_id_fk", + "tableFrom": "post_read", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "post_read_post_id_external_user_id_pk": { + "name": "post_read_post_id_external_user_id_pk", + "columns": [ + "post_id", + "external_user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.media_kind": { + "name": "media_kind", + "schema": "public", + "values": [ + "image", + "video" + ] + }, + "public.issue_status": { + "name": "issue_status", + "schema": "public", + "values": [ + "draft", + "scheduled", + "published" + ] + }, + "public.post_audience": { + "name": "post_audience", + "schema": "public", + "values": [ + "internal", + "customer", + "public" + ] + }, + "public.post_locale": { + "name": "post_locale", + "schema": "public", + "values": [ + "de", + "en" + ] + }, + "public.post_status": { + "name": "post_status", + "schema": "public", + "values": [ + "draft", + "review", + "scheduled", + "published", + "archived" + ] + }, + "public.post_type": { + "name": "post_type", + "schema": "public", + "values": [ + "feature", + "improvement", + "fix", + "breaking", + "info" + ] + }, + "public.client_mode": { + "name": "client_mode", + "schema": "public", + "values": [ + "read", + "write" + ] + }, + "public.client_scope": { + "name": "client_scope", + "schema": "public", + "values": [ + "internal", + "customer", + "public" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0004_snapshot.json b/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..04cfeb4 --- /dev/null +++ b/drizzle/meta/0004_snapshot.json @@ -0,0 +1,1519 @@ +{ + "id": "ccfb1097-96b8-4b9a-a088-4d78dc118a46", + "prevId": "5058fde9-f0f9-4300-b114-85c278913f85", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_user": { + "name": "account_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "session_user": { + "name": "session_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_project_role": { + "name": "user_project_role", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "project_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'moderator'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_project_role_project": { + "name": "user_project_role_project", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_project_role_user_id_user_id_fk": { + "name": "user_project_role_user_id_user_id_fk", + "tableFrom": "user_project_role", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_project_role_project_id_project_id_fk": { + "name": "user_project_role_project_id_project_id_fk", + "tableFrom": "user_project_role", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_project_role_user_id_project_id_pk": { + "name": "user_project_role_user_id_project_id_pk", + "columns": [ + "user_id", + "project_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier": { + "name": "verification_identifier", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brand": { + "name": "brand", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#191c20'" + }, + "sort": { + "name": "sort", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "brand_slug_unique": { + "name": "brand_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project": { + "name": "project", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "brand_id": { + "name": "brand_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#191c20'" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sort": { + "name": "sort", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "project_brand_id_brand_id_fk": { + "name": "project_brand_id_brand_id_fk", + "tableFrom": "project", + "tableTo": "brand", + "columnsFrom": [ + "brand_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_slug_unique": { + "name": "project_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media": { + "name": "media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "media_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'image'" + }, + "original_filename": { + "name": "original_filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime": { + "name": "mime", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "variants": { + "name": "variants", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "alt": { + "name": "alt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "caption": { + "name": "caption", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "media_project_id_project_id_fk": { + "name": "media_project_id_project_id_fk", + "tableFrom": "media", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.block": { + "name": "block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sort": { + "name": "sort", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "block_post_sort": { + "name": "block_post_sort", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "block_post_id_post_id_fk": { + "name": "block_post_id_post_id_fk", + "tableFrom": "block", + "tableTo": "post", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue": { + "name": "issue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_from": { + "name": "period_from", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "period_to": { + "name": "period_to", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "issue_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "publish_at": { + "name": "publish_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "issue_project_id_project_id_fk": { + "name": "issue_project_id_project_id_fk", + "tableFrom": "issue", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_tag": { + "name": "post_tag", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "post_tag_post_id_post_id_fk": { + "name": "post_tag_post_id_post_id_fk", + "tableFrom": "post_tag", + "tableTo": "post", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_tag_tag_id_tag_id_fk": { + "name": "post_tag_tag_id_tag_id_fk", + "tableFrom": "post_tag", + "tableTo": "tag", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "post_tag_post_id_tag_id_pk": { + "name": "post_tag_post_id_tag_id_pk", + "columns": [ + "post_id", + "tag_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post": { + "name": "post", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "teaser": { + "name": "teaser", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "post_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'feature'" + }, + "audience": { + "name": "audience", + "type": "post_audience", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "status": { + "name": "status", + "type": "post_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "locale": { + "name": "locale", + "type": "post_locale", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'de'" + }, + "translation_group": { + "name": "translation_group", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "publish_at": { + "name": "publish_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_media_id": { + "name": "cover_media_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "post_publish_at": { + "name": "post_publish_at", + "columns": [ + { + "expression": "publish_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_project_status": { + "name": "post_project_status", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "post_project_id_project_id_fk": { + "name": "post_project_id_project_id_fk", + "tableFrom": "post", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_issue_id_issue_id_fk": { + "name": "post_issue_id_issue_id_fk", + "tableFrom": "post", + "tableTo": "issue", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "post_cover_media_id_media_id_fk": { + "name": "post_cover_media_id_media_id_fk", + "tableFrom": "post", + "tableTo": "media", + "columnsFrom": [ + "cover_media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "post_project_slug": { + "name": "post_project_slug", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "slug" + ] + }, + "post_project_number": { + "name": "post_project_number", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tag": { + "name": "tag", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "tag_project_id_project_id_fk": { + "name": "tag_project_id_project_id_fk", + "tableFrom": "tag", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tag_project_slug": { + "name": "tag_project_slug", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_client": { + "name": "api_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "client_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'read'" + }, + "scope": { + "name": "scope", + "type": "client_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'customer'" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "can_publish": { + "name": "can_publish", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "api_client_project_id_project_id_fk": { + "name": "api_client_project_id_project_id_fk", + "tableFrom": "api_client", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_client_token_hash_unique": { + "name": "api_client_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_read": { + "name": "post_read", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "external_user_id": { + "name": "external_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "post_read_project_user": { + "name": "post_read_project_user", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "post_read_post_id_post_id_fk": { + "name": "post_read_post_id_post_id_fk", + "tableFrom": "post_read", + "tableTo": "post", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_read_project_id_project_id_fk": { + "name": "post_read_project_id_project_id_fk", + "tableFrom": "post_read", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "post_read_post_id_external_user_id_pk": { + "name": "post_read_post_id_external_user_id_pk", + "columns": [ + "post_id", + "external_user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.project_role": { + "name": "project_role", + "schema": "public", + "values": [ + "moderator" + ] + }, + "public.media_kind": { + "name": "media_kind", + "schema": "public", + "values": [ + "image", + "video" + ] + }, + "public.issue_status": { + "name": "issue_status", + "schema": "public", + "values": [ + "draft", + "scheduled", + "published" + ] + }, + "public.post_audience": { + "name": "post_audience", + "schema": "public", + "values": [ + "internal", + "customer", + "public" + ] + }, + "public.post_locale": { + "name": "post_locale", + "schema": "public", + "values": [ + "de", + "en" + ] + }, + "public.post_status": { + "name": "post_status", + "schema": "public", + "values": [ + "draft", + "review", + "scheduled", + "published", + "archived" + ] + }, + "public.post_type": { + "name": "post_type", + "schema": "public", + "values": [ + "feature", + "improvement", + "fix", + "breaking", + "info" + ] + }, + "public.client_mode": { + "name": "client_mode", + "schema": "public", + "values": [ + "read", + "write" + ] + }, + "public.client_scope": { + "name": "client_scope", + "schema": "public", + "values": [ + "internal", + "customer", + "public" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0005_snapshot.json b/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..559a274 --- /dev/null +++ b/drizzle/meta/0005_snapshot.json @@ -0,0 +1,1636 @@ +{ + "id": "d1d3f30a-f2ad-44eb-b7fa-08a3417c5e4a", + "prevId": "ccfb1097-96b8-4b9a-a088-4d78dc118a46", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_type": { + "name": "actor_type", + "type": "audit_actor_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_label": { + "name": "actor_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity": { + "name": "entity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_created": { + "name": "audit_log_created", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_entity": { + "name": "audit_log_entity", + "columns": [ + { + "expression": "entity", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_user": { + "name": "account_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "session_user": { + "name": "session_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_project_role": { + "name": "user_project_role", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "project_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'moderator'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_project_role_project": { + "name": "user_project_role_project", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_project_role_user_id_user_id_fk": { + "name": "user_project_role_user_id_user_id_fk", + "tableFrom": "user_project_role", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_project_role_project_id_project_id_fk": { + "name": "user_project_role_project_id_project_id_fk", + "tableFrom": "user_project_role", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_project_role_user_id_project_id_pk": { + "name": "user_project_role_user_id_project_id_pk", + "columns": [ + "user_id", + "project_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier": { + "name": "verification_identifier", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brand": { + "name": "brand", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#191c20'" + }, + "sort": { + "name": "sort", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "brand_slug_unique": { + "name": "brand_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project": { + "name": "project", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "brand_id": { + "name": "brand_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#191c20'" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sort": { + "name": "sort", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "project_brand_id_brand_id_fk": { + "name": "project_brand_id_brand_id_fk", + "tableFrom": "project", + "tableTo": "brand", + "columnsFrom": [ + "brand_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_slug_unique": { + "name": "project_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media": { + "name": "media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "media_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'image'" + }, + "original_filename": { + "name": "original_filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime": { + "name": "mime", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "variants": { + "name": "variants", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "alt": { + "name": "alt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "caption": { + "name": "caption", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "media_project_id_project_id_fk": { + "name": "media_project_id_project_id_fk", + "tableFrom": "media", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.block": { + "name": "block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sort": { + "name": "sort", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "block_post_sort": { + "name": "block_post_sort", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "block_post_id_post_id_fk": { + "name": "block_post_id_post_id_fk", + "tableFrom": "block", + "tableTo": "post", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue": { + "name": "issue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_from": { + "name": "period_from", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "period_to": { + "name": "period_to", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "issue_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "publish_at": { + "name": "publish_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "issue_project_id_project_id_fk": { + "name": "issue_project_id_project_id_fk", + "tableFrom": "issue", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_tag": { + "name": "post_tag", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "post_tag_post_id_post_id_fk": { + "name": "post_tag_post_id_post_id_fk", + "tableFrom": "post_tag", + "tableTo": "post", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_tag_tag_id_tag_id_fk": { + "name": "post_tag_tag_id_tag_id_fk", + "tableFrom": "post_tag", + "tableTo": "tag", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "post_tag_post_id_tag_id_pk": { + "name": "post_tag_post_id_tag_id_pk", + "columns": [ + "post_id", + "tag_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post": { + "name": "post", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "teaser": { + "name": "teaser", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "post_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'feature'" + }, + "audience": { + "name": "audience", + "type": "post_audience", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "status": { + "name": "status", + "type": "post_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "locale": { + "name": "locale", + "type": "post_locale", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'de'" + }, + "translation_group": { + "name": "translation_group", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "publish_at": { + "name": "publish_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_media_id": { + "name": "cover_media_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "post_publish_at": { + "name": "post_publish_at", + "columns": [ + { + "expression": "publish_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_project_status": { + "name": "post_project_status", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "post_project_id_project_id_fk": { + "name": "post_project_id_project_id_fk", + "tableFrom": "post", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_issue_id_issue_id_fk": { + "name": "post_issue_id_issue_id_fk", + "tableFrom": "post", + "tableTo": "issue", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "post_cover_media_id_media_id_fk": { + "name": "post_cover_media_id_media_id_fk", + "tableFrom": "post", + "tableTo": "media", + "columnsFrom": [ + "cover_media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "post_project_slug": { + "name": "post_project_slug", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "slug" + ] + }, + "post_project_number": { + "name": "post_project_number", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tag": { + "name": "tag", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "tag_project_id_project_id_fk": { + "name": "tag_project_id_project_id_fk", + "tableFrom": "tag", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tag_project_slug": { + "name": "tag_project_slug", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_client": { + "name": "api_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "client_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'read'" + }, + "scope": { + "name": "scope", + "type": "client_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'customer'" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "can_publish": { + "name": "can_publish", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "api_client_project_id_project_id_fk": { + "name": "api_client_project_id_project_id_fk", + "tableFrom": "api_client", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_client_token_hash_unique": { + "name": "api_client_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_read": { + "name": "post_read", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "external_user_id": { + "name": "external_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "post_read_project_user": { + "name": "post_read_project_user", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "post_read_post_id_post_id_fk": { + "name": "post_read_post_id_post_id_fk", + "tableFrom": "post_read", + "tableTo": "post", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_read_project_id_project_id_fk": { + "name": "post_read_project_id_project_id_fk", + "tableFrom": "post_read", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "post_read_post_id_external_user_id_pk": { + "name": "post_read_post_id_external_user_id_pk", + "columns": [ + "post_id", + "external_user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.audit_actor_type": { + "name": "audit_actor_type", + "schema": "public", + "values": [ + "user", + "client" + ] + }, + "public.project_role": { + "name": "project_role", + "schema": "public", + "values": [ + "moderator" + ] + }, + "public.media_kind": { + "name": "media_kind", + "schema": "public", + "values": [ + "image", + "video" + ] + }, + "public.issue_status": { + "name": "issue_status", + "schema": "public", + "values": [ + "draft", + "scheduled", + "published" + ] + }, + "public.post_audience": { + "name": "post_audience", + "schema": "public", + "values": [ + "internal", + "customer", + "public" + ] + }, + "public.post_locale": { + "name": "post_locale", + "schema": "public", + "values": [ + "de", + "en" + ] + }, + "public.post_status": { + "name": "post_status", + "schema": "public", + "values": [ + "draft", + "review", + "scheduled", + "published", + "archived" + ] + }, + "public.post_type": { + "name": "post_type", + "schema": "public", + "values": [ + "feature", + "improvement", + "fix", + "breaking", + "info" + ] + }, + "public.client_mode": { + "name": "client_mode", + "schema": "public", + "values": [ + "read", + "write" + ] + }, + "public.client_scope": { + "name": "client_scope", + "schema": "public", + "values": [ + "internal", + "customer", + "public" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0006_snapshot.json b/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..ae60125 --- /dev/null +++ b/drizzle/meta/0006_snapshot.json @@ -0,0 +1,1689 @@ +{ + "id": "de95ee09-8a89-4885-954b-579fa088c4af", + "prevId": "d1d3f30a-f2ad-44eb-b7fa-08a3417c5e4a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_type": { + "name": "actor_type", + "type": "audit_actor_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_label": { + "name": "actor_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity": { + "name": "entity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_created": { + "name": "audit_log_created", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_entity": { + "name": "audit_log_entity", + "columns": [ + { + "expression": "entity", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_user": { + "name": "account_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "session_user": { + "name": "session_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_project_role": { + "name": "user_project_role", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "project_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'moderator'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_project_role_project": { + "name": "user_project_role_project", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_project_role_user_id_user_id_fk": { + "name": "user_project_role_user_id_user_id_fk", + "tableFrom": "user_project_role", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_project_role_project_id_project_id_fk": { + "name": "user_project_role_project_id_project_id_fk", + "tableFrom": "user_project_role", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_project_role_user_id_project_id_pk": { + "name": "user_project_role_user_id_project_id_pk", + "columns": [ + "user_id", + "project_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier": { + "name": "verification_identifier", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brand": { + "name": "brand", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#191c20'" + }, + "sort": { + "name": "sort", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "brand_slug_unique": { + "name": "brand_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project": { + "name": "project", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "brand_id": { + "name": "brand_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#191c20'" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sort": { + "name": "sort", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "project_brand_id_brand_id_fk": { + "name": "project_brand_id_brand_id_fk", + "tableFrom": "project", + "tableTo": "brand", + "columnsFrom": [ + "brand_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_slug_unique": { + "name": "project_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media": { + "name": "media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "media_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'image'" + }, + "original_filename": { + "name": "original_filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime": { + "name": "mime", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "variants": { + "name": "variants", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "variant_error": { + "name": "variant_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "alt": { + "name": "alt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "caption": { + "name": "caption", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "media_project_created": { + "name": "media_project_created", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "media_project_id_project_id_fk": { + "name": "media_project_id_project_id_fk", + "tableFrom": "media", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "media_uploaded_by_user_id_fk": { + "name": "media_uploaded_by_user_id_fk", + "tableFrom": "media", + "tableTo": "user", + "columnsFrom": [ + "uploaded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.block": { + "name": "block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sort": { + "name": "sort", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "block_post_sort": { + "name": "block_post_sort", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "block_post_id_post_id_fk": { + "name": "block_post_id_post_id_fk", + "tableFrom": "block", + "tableTo": "post", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue": { + "name": "issue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_from": { + "name": "period_from", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "period_to": { + "name": "period_to", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "issue_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "publish_at": { + "name": "publish_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "issue_project_id_project_id_fk": { + "name": "issue_project_id_project_id_fk", + "tableFrom": "issue", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_tag": { + "name": "post_tag", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "post_tag_post_id_post_id_fk": { + "name": "post_tag_post_id_post_id_fk", + "tableFrom": "post_tag", + "tableTo": "post", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_tag_tag_id_tag_id_fk": { + "name": "post_tag_tag_id_tag_id_fk", + "tableFrom": "post_tag", + "tableTo": "tag", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "post_tag_post_id_tag_id_pk": { + "name": "post_tag_post_id_tag_id_pk", + "columns": [ + "post_id", + "tag_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post": { + "name": "post", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "teaser": { + "name": "teaser", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "post_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'feature'" + }, + "audience": { + "name": "audience", + "type": "post_audience", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "status": { + "name": "status", + "type": "post_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "locale": { + "name": "locale", + "type": "post_locale", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'de'" + }, + "translation_group": { + "name": "translation_group", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "publish_at": { + "name": "publish_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_media_id": { + "name": "cover_media_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "post_publish_at": { + "name": "post_publish_at", + "columns": [ + { + "expression": "publish_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_project_status": { + "name": "post_project_status", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "post_project_id_project_id_fk": { + "name": "post_project_id_project_id_fk", + "tableFrom": "post", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_issue_id_issue_id_fk": { + "name": "post_issue_id_issue_id_fk", + "tableFrom": "post", + "tableTo": "issue", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "post_cover_media_id_media_id_fk": { + "name": "post_cover_media_id_media_id_fk", + "tableFrom": "post", + "tableTo": "media", + "columnsFrom": [ + "cover_media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "post_project_slug": { + "name": "post_project_slug", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "slug" + ] + }, + "post_project_number": { + "name": "post_project_number", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tag": { + "name": "tag", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "tag_project_id_project_id_fk": { + "name": "tag_project_id_project_id_fk", + "tableFrom": "tag", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tag_project_slug": { + "name": "tag_project_slug", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_client": { + "name": "api_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "client_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'read'" + }, + "scope": { + "name": "scope", + "type": "client_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'customer'" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "can_publish": { + "name": "can_publish", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "api_client_project_id_project_id_fk": { + "name": "api_client_project_id_project_id_fk", + "tableFrom": "api_client", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_client_token_hash_unique": { + "name": "api_client_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_read": { + "name": "post_read", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "external_user_id": { + "name": "external_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "post_read_project_user": { + "name": "post_read_project_user", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "post_read_post_id_post_id_fk": { + "name": "post_read_post_id_post_id_fk", + "tableFrom": "post_read", + "tableTo": "post", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_read_project_id_project_id_fk": { + "name": "post_read_project_id_project_id_fk", + "tableFrom": "post_read", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "post_read_post_id_external_user_id_pk": { + "name": "post_read_post_id_external_user_id_pk", + "columns": [ + "post_id", + "external_user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.audit_actor_type": { + "name": "audit_actor_type", + "schema": "public", + "values": [ + "user", + "client" + ] + }, + "public.project_role": { + "name": "project_role", + "schema": "public", + "values": [ + "moderator" + ] + }, + "public.media_kind": { + "name": "media_kind", + "schema": "public", + "values": [ + "image", + "video" + ] + }, + "public.issue_status": { + "name": "issue_status", + "schema": "public", + "values": [ + "draft", + "scheduled", + "published" + ] + }, + "public.post_audience": { + "name": "post_audience", + "schema": "public", + "values": [ + "internal", + "customer", + "public" + ] + }, + "public.post_locale": { + "name": "post_locale", + "schema": "public", + "values": [ + "de", + "en" + ] + }, + "public.post_status": { + "name": "post_status", + "schema": "public", + "values": [ + "draft", + "review", + "scheduled", + "published", + "archived" + ] + }, + "public.post_type": { + "name": "post_type", + "schema": "public", + "values": [ + "feature", + "improvement", + "fix", + "breaking", + "info" + ] + }, + "public.client_mode": { + "name": "client_mode", + "schema": "public", + "values": [ + "read", + "write" + ] + }, + "public.client_scope": { + "name": "client_scope", + "schema": "public", + "values": [ + "internal", + "customer", + "public" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0007_snapshot.json b/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000..2d9c304 --- /dev/null +++ b/drizzle/meta/0007_snapshot.json @@ -0,0 +1,1729 @@ +{ + "id": "9cde7ed0-759d-4de8-adcc-0b532482bade", + "prevId": "de95ee09-8a89-4885-954b-579fa088c4af", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_type": { + "name": "actor_type", + "type": "audit_actor_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_label": { + "name": "actor_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity": { + "name": "entity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_created": { + "name": "audit_log_created", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_entity": { + "name": "audit_log_entity", + "columns": [ + { + "expression": "entity", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_user": { + "name": "account_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "session_user": { + "name": "session_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_project_role": { + "name": "user_project_role", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "project_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'moderator'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_project_role_project": { + "name": "user_project_role_project", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_project_role_user_id_user_id_fk": { + "name": "user_project_role_user_id_user_id_fk", + "tableFrom": "user_project_role", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_project_role_project_id_project_id_fk": { + "name": "user_project_role_project_id_project_id_fk", + "tableFrom": "user_project_role", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_project_role_user_id_project_id_pk": { + "name": "user_project_role_user_id_project_id_pk", + "columns": [ + "user_id", + "project_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier": { + "name": "verification_identifier", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brand": { + "name": "brand", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#191c20'" + }, + "sort": { + "name": "sort", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "brand_slug_unique": { + "name": "brand_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project": { + "name": "project", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "brand_id": { + "name": "brand_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#191c20'" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sort": { + "name": "sort", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "project_brand_id_brand_id_fk": { + "name": "project_brand_id_brand_id_fk", + "tableFrom": "project", + "tableTo": "brand", + "columnsFrom": [ + "brand_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_slug_unique": { + "name": "project_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_counter": { + "name": "post_counter", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "post_counter_project_id_project_id_fk": { + "name": "post_counter_project_id_project_id_fk", + "tableFrom": "post_counter", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media": { + "name": "media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "media_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'image'" + }, + "original_filename": { + "name": "original_filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime": { + "name": "mime", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "variants": { + "name": "variants", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "variant_error": { + "name": "variant_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "alt": { + "name": "alt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "caption": { + "name": "caption", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "media_project_created": { + "name": "media_project_created", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "media_project_id_project_id_fk": { + "name": "media_project_id_project_id_fk", + "tableFrom": "media", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "media_uploaded_by_user_id_fk": { + "name": "media_uploaded_by_user_id_fk", + "tableFrom": "media", + "tableTo": "user", + "columnsFrom": [ + "uploaded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.block": { + "name": "block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sort": { + "name": "sort", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "block_post_sort": { + "name": "block_post_sort", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "block_post_id_post_id_fk": { + "name": "block_post_id_post_id_fk", + "tableFrom": "block", + "tableTo": "post", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue": { + "name": "issue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_from": { + "name": "period_from", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "period_to": { + "name": "period_to", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "issue_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "publish_at": { + "name": "publish_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "issue_project_id_project_id_fk": { + "name": "issue_project_id_project_id_fk", + "tableFrom": "issue", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_tag": { + "name": "post_tag", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "post_tag_post_id_post_id_fk": { + "name": "post_tag_post_id_post_id_fk", + "tableFrom": "post_tag", + "tableTo": "post", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_tag_tag_id_tag_id_fk": { + "name": "post_tag_tag_id_tag_id_fk", + "tableFrom": "post_tag", + "tableTo": "tag", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "post_tag_post_id_tag_id_pk": { + "name": "post_tag_post_id_tag_id_pk", + "columns": [ + "post_id", + "tag_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post": { + "name": "post", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "teaser": { + "name": "teaser", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "post_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'feature'" + }, + "audience": { + "name": "audience", + "type": "post_audience", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "status": { + "name": "status", + "type": "post_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "locale": { + "name": "locale", + "type": "post_locale", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'de'" + }, + "translation_group": { + "name": "translation_group", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "publish_at": { + "name": "publish_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_media_id": { + "name": "cover_media_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "post_publish_at": { + "name": "post_publish_at", + "columns": [ + { + "expression": "publish_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_project_status": { + "name": "post_project_status", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "post_project_id_project_id_fk": { + "name": "post_project_id_project_id_fk", + "tableFrom": "post", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_issue_id_issue_id_fk": { + "name": "post_issue_id_issue_id_fk", + "tableFrom": "post", + "tableTo": "issue", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "post_cover_media_id_media_id_fk": { + "name": "post_cover_media_id_media_id_fk", + "tableFrom": "post", + "tableTo": "media", + "columnsFrom": [ + "cover_media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "post_project_slug": { + "name": "post_project_slug", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "slug" + ] + }, + "post_project_number": { + "name": "post_project_number", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tag": { + "name": "tag", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "tag_project_id_project_id_fk": { + "name": "tag_project_id_project_id_fk", + "tableFrom": "tag", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tag_project_slug": { + "name": "tag_project_slug", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_client": { + "name": "api_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "client_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'read'" + }, + "scope": { + "name": "scope", + "type": "client_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'customer'" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "can_publish": { + "name": "can_publish", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "api_client_project_id_project_id_fk": { + "name": "api_client_project_id_project_id_fk", + "tableFrom": "api_client", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_client_token_hash_unique": { + "name": "api_client_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_read": { + "name": "post_read", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "external_user_id": { + "name": "external_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "post_read_project_user": { + "name": "post_read_project_user", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "post_read_post_id_post_id_fk": { + "name": "post_read_post_id_post_id_fk", + "tableFrom": "post_read", + "tableTo": "post", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_read_project_id_project_id_fk": { + "name": "post_read_project_id_project_id_fk", + "tableFrom": "post_read", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "post_read_post_id_external_user_id_pk": { + "name": "post_read_post_id_external_user_id_pk", + "columns": [ + "post_id", + "external_user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.audit_actor_type": { + "name": "audit_actor_type", + "schema": "public", + "values": [ + "user", + "client" + ] + }, + "public.project_role": { + "name": "project_role", + "schema": "public", + "values": [ + "moderator" + ] + }, + "public.media_kind": { + "name": "media_kind", + "schema": "public", + "values": [ + "image", + "video" + ] + }, + "public.issue_status": { + "name": "issue_status", + "schema": "public", + "values": [ + "draft", + "scheduled", + "published" + ] + }, + "public.post_audience": { + "name": "post_audience", + "schema": "public", + "values": [ + "internal", + "customer", + "public" + ] + }, + "public.post_locale": { + "name": "post_locale", + "schema": "public", + "values": [ + "de", + "en" + ] + }, + "public.post_status": { + "name": "post_status", + "schema": "public", + "values": [ + "draft", + "review", + "scheduled", + "published", + "archived" + ] + }, + "public.post_type": { + "name": "post_type", + "schema": "public", + "values": [ + "feature", + "improvement", + "fix", + "breaking", + "info" + ] + }, + "public.client_mode": { + "name": "client_mode", + "schema": "public", + "values": [ + "read", + "write" + ] + }, + "public.client_scope": { + "name": "client_scope", + "schema": "public", + "values": [ + "internal", + "customer", + "public" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0008_snapshot.json b/drizzle/meta/0008_snapshot.json new file mode 100644 index 0000000..165e230 --- /dev/null +++ b/drizzle/meta/0008_snapshot.json @@ -0,0 +1,1918 @@ +{ + "id": "cef5b1d7-55ba-47f2-b511-61a20ce277e3", + "prevId": "9cde7ed0-759d-4de8-adcc-0b532482bade", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_type": { + "name": "actor_type", + "type": "audit_actor_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_label": { + "name": "actor_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity": { + "name": "entity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_created": { + "name": "audit_log_created", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_entity": { + "name": "audit_log_entity", + "columns": [ + { + "expression": "entity", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_user": { + "name": "account_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "session_user": { + "name": "session_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_project_role": { + "name": "user_project_role", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "project_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'moderator'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_project_role_project": { + "name": "user_project_role_project", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_project_role_user_id_user_id_fk": { + "name": "user_project_role_user_id_user_id_fk", + "tableFrom": "user_project_role", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_project_role_project_id_project_id_fk": { + "name": "user_project_role_project_id_project_id_fk", + "tableFrom": "user_project_role", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_project_role_user_id_project_id_pk": { + "name": "user_project_role_user_id_project_id_pk", + "columns": [ + "user_id", + "project_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier": { + "name": "verification_identifier", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brand": { + "name": "brand", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#191c20'" + }, + "sort": { + "name": "sort", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "brand_slug_unique": { + "name": "brand_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project": { + "name": "project", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "brand_id": { + "name": "brand_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#191c20'" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sort": { + "name": "sort", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "project_brand_id_brand_id_fk": { + "name": "project_brand_id_brand_id_fk", + "tableFrom": "project", + "tableTo": "brand", + "columnsFrom": [ + "brand_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_slug_unique": { + "name": "project_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_counter": { + "name": "post_counter", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "post_counter_project_id_project_id_fk": { + "name": "post_counter_project_id_project_id_fk", + "tableFrom": "post_counter", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media": { + "name": "media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "media_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'image'" + }, + "original_filename": { + "name": "original_filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime": { + "name": "mime", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "variants": { + "name": "variants", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "variant_error": { + "name": "variant_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "alt": { + "name": "alt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "caption": { + "name": "caption", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "media_project_created": { + "name": "media_project_created", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "media_project_id_project_id_fk": { + "name": "media_project_id_project_id_fk", + "tableFrom": "media", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "media_uploaded_by_user_id_fk": { + "name": "media_uploaded_by_user_id_fk", + "tableFrom": "media", + "tableTo": "user", + "columnsFrom": [ + "uploaded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_type": { + "name": "post_type", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label_de": { + "name": "label_de", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label_en": { + "name": "label_en", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#191c20'" + }, + "sort": { + "name": "sort", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "post_type_sort": { + "name": "post_type_sort", + "columns": [ + { + "expression": "sort", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "post_type_key_unique": { + "name": "post_type_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.block": { + "name": "block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sort": { + "name": "sort", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "block_post_sort": { + "name": "block_post_sort", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "block_post_id_post_id_fk": { + "name": "block_post_id_post_id_fk", + "tableFrom": "block", + "tableTo": "post", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue": { + "name": "issue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_from": { + "name": "period_from", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "period_to": { + "name": "period_to", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "issue_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "publish_at": { + "name": "publish_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "issue_project_id_project_id_fk": { + "name": "issue_project_id_project_id_fk", + "tableFrom": "issue", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_tag": { + "name": "post_tag", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "post_tag_post_id_post_id_fk": { + "name": "post_tag_post_id_post_id_fk", + "tableFrom": "post_tag", + "tableTo": "post", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_tag_tag_id_tag_id_fk": { + "name": "post_tag_tag_id_tag_id_fk", + "tableFrom": "post_tag", + "tableTo": "tag", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "post_tag_post_id_tag_id_pk": { + "name": "post_tag_post_id_tag_id_pk", + "columns": [ + "post_id", + "tag_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post": { + "name": "post", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "teaser": { + "name": "teaser", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "post_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'feature'" + }, + "type_id": { + "name": "type_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "post_type_default()" + }, + "audience": { + "name": "audience", + "type": "post_audience", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "status": { + "name": "status", + "type": "post_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "locale": { + "name": "locale", + "type": "post_locale", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'de'" + }, + "translation_group": { + "name": "translation_group", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "publish_at": { + "name": "publish_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_media_id": { + "name": "cover_media_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "post_publish_at": { + "name": "post_publish_at", + "columns": [ + { + "expression": "publish_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_project_status": { + "name": "post_project_status", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "post_project_id_project_id_fk": { + "name": "post_project_id_project_id_fk", + "tableFrom": "post", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_issue_id_issue_id_fk": { + "name": "post_issue_id_issue_id_fk", + "tableFrom": "post", + "tableTo": "issue", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "post_type_id_post_type_id_fk": { + "name": "post_type_id_post_type_id_fk", + "tableFrom": "post", + "tableTo": "post_type", + "columnsFrom": [ + "type_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "post_cover_media_id_media_id_fk": { + "name": "post_cover_media_id_media_id_fk", + "tableFrom": "post", + "tableTo": "media", + "columnsFrom": [ + "cover_media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "post_project_slug": { + "name": "post_project_slug", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "slug" + ] + }, + "post_project_number": { + "name": "post_project_number", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tag": { + "name": "tag", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "tag_project_id_project_id_fk": { + "name": "tag_project_id_project_id_fk", + "tableFrom": "tag", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tag_project_slug": { + "name": "tag_project_slug", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_client": { + "name": "api_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "client_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'read'" + }, + "scope": { + "name": "scope", + "type": "client_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'customer'" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "can_publish": { + "name": "can_publish", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "api_client_project_id_project_id_fk": { + "name": "api_client_project_id_project_id_fk", + "tableFrom": "api_client", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_client_token_hash_unique": { + "name": "api_client_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_read": { + "name": "post_read", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "external_user_id": { + "name": "external_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "post_read_project_user": { + "name": "post_read_project_user", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "post_read_post_id_post_id_fk": { + "name": "post_read_post_id_post_id_fk", + "tableFrom": "post_read", + "tableTo": "post", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_read_project_id_project_id_fk": { + "name": "post_read_project_id_project_id_fk", + "tableFrom": "post_read", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "post_read_post_id_external_user_id_pk": { + "name": "post_read_post_id_external_user_id_pk", + "columns": [ + "post_id", + "external_user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_idempotency_key": { + "name": "api_idempotency_key", + "schema": "", + "columns": { + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "api_idempotency_key_client_id_api_client_id_fk": { + "name": "api_idempotency_key_client_id_api_client_id_fk", + "tableFrom": "api_idempotency_key", + "tableTo": "api_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_idempotency_key_post_id_post_id_fk": { + "name": "api_idempotency_key_post_id_post_id_fk", + "tableFrom": "api_idempotency_key", + "tableTo": "post", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "api_idempotency_key_client_id_key_pk": { + "name": "api_idempotency_key_client_id_key_pk", + "columns": [ + "client_id", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.audit_actor_type": { + "name": "audit_actor_type", + "schema": "public", + "values": [ + "user", + "client" + ] + }, + "public.project_role": { + "name": "project_role", + "schema": "public", + "values": [ + "moderator" + ] + }, + "public.media_kind": { + "name": "media_kind", + "schema": "public", + "values": [ + "image", + "video" + ] + }, + "public.issue_status": { + "name": "issue_status", + "schema": "public", + "values": [ + "draft", + "scheduled", + "published" + ] + }, + "public.post_audience": { + "name": "post_audience", + "schema": "public", + "values": [ + "internal", + "customer", + "public" + ] + }, + "public.post_locale": { + "name": "post_locale", + "schema": "public", + "values": [ + "de", + "en" + ] + }, + "public.post_status": { + "name": "post_status", + "schema": "public", + "values": [ + "draft", + "review", + "scheduled", + "published", + "archived" + ] + }, + "public.post_type": { + "name": "post_type", + "schema": "public", + "values": [ + "feature", + "improvement", + "fix", + "breaking", + "info" + ] + }, + "public.client_mode": { + "name": "client_mode", + "schema": "public", + "values": [ + "read", + "write" + ] + }, + "public.client_scope": { + "name": "client_scope", + "schema": "public", + "values": [ + "internal", + "customer", + "public" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0009_snapshot.json b/drizzle/meta/0009_snapshot.json new file mode 100644 index 0000000..bf850d8 --- /dev/null +++ b/drizzle/meta/0009_snapshot.json @@ -0,0 +1,1899 @@ +{ + "id": "12dc209e-f668-4e06-85fb-442148784ce7", + "prevId": "cef5b1d7-55ba-47f2-b511-61a20ce277e3", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_type": { + "name": "actor_type", + "type": "audit_actor_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_label": { + "name": "actor_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity": { + "name": "entity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_created": { + "name": "audit_log_created", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_entity": { + "name": "audit_log_entity", + "columns": [ + { + "expression": "entity", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_user": { + "name": "account_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "session_user": { + "name": "session_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_project_role": { + "name": "user_project_role", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "project_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'moderator'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_project_role_project": { + "name": "user_project_role_project", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_project_role_user_id_user_id_fk": { + "name": "user_project_role_user_id_user_id_fk", + "tableFrom": "user_project_role", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_project_role_project_id_project_id_fk": { + "name": "user_project_role_project_id_project_id_fk", + "tableFrom": "user_project_role", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_project_role_user_id_project_id_pk": { + "name": "user_project_role_user_id_project_id_pk", + "columns": [ + "user_id", + "project_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier": { + "name": "verification_identifier", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brand": { + "name": "brand", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#191c20'" + }, + "sort": { + "name": "sort", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "brand_slug_unique": { + "name": "brand_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project": { + "name": "project", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "brand_id": { + "name": "brand_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#191c20'" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sort": { + "name": "sort", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "project_brand_id_brand_id_fk": { + "name": "project_brand_id_brand_id_fk", + "tableFrom": "project", + "tableTo": "brand", + "columnsFrom": [ + "brand_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_slug_unique": { + "name": "project_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_counter": { + "name": "post_counter", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "post_counter_project_id_project_id_fk": { + "name": "post_counter_project_id_project_id_fk", + "tableFrom": "post_counter", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media": { + "name": "media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "media_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'image'" + }, + "original_filename": { + "name": "original_filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime": { + "name": "mime", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "variants": { + "name": "variants", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "variant_error": { + "name": "variant_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "alt": { + "name": "alt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "caption": { + "name": "caption", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "media_project_created": { + "name": "media_project_created", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "media_project_id_project_id_fk": { + "name": "media_project_id_project_id_fk", + "tableFrom": "media", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "media_uploaded_by_user_id_fk": { + "name": "media_uploaded_by_user_id_fk", + "tableFrom": "media", + "tableTo": "user", + "columnsFrom": [ + "uploaded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_type": { + "name": "post_type", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label_de": { + "name": "label_de", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label_en": { + "name": "label_en", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#191c20'" + }, + "sort": { + "name": "sort", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "post_type_sort": { + "name": "post_type_sort", + "columns": [ + { + "expression": "sort", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "post_type_key_unique": { + "name": "post_type_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.block": { + "name": "block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sort": { + "name": "sort", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "block_post_sort": { + "name": "block_post_sort", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "block_post_id_post_id_fk": { + "name": "block_post_id_post_id_fk", + "tableFrom": "block", + "tableTo": "post", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue": { + "name": "issue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_from": { + "name": "period_from", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "period_to": { + "name": "period_to", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "issue_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "publish_at": { + "name": "publish_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "issue_project_id_project_id_fk": { + "name": "issue_project_id_project_id_fk", + "tableFrom": "issue", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_tag": { + "name": "post_tag", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "post_tag_post_id_post_id_fk": { + "name": "post_tag_post_id_post_id_fk", + "tableFrom": "post_tag", + "tableTo": "post", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_tag_tag_id_tag_id_fk": { + "name": "post_tag_tag_id_tag_id_fk", + "tableFrom": "post_tag", + "tableTo": "tag", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "post_tag_post_id_tag_id_pk": { + "name": "post_tag_post_id_tag_id_pk", + "columns": [ + "post_id", + "tag_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post": { + "name": "post", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "teaser": { + "name": "teaser", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type_id": { + "name": "type_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "post_type_default()" + }, + "audience": { + "name": "audience", + "type": "post_audience", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "status": { + "name": "status", + "type": "post_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "locale": { + "name": "locale", + "type": "post_locale", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'de'" + }, + "translation_group": { + "name": "translation_group", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "publish_at": { + "name": "publish_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_media_id": { + "name": "cover_media_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "post_publish_at": { + "name": "post_publish_at", + "columns": [ + { + "expression": "publish_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_project_status": { + "name": "post_project_status", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "post_project_id_project_id_fk": { + "name": "post_project_id_project_id_fk", + "tableFrom": "post", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_issue_id_issue_id_fk": { + "name": "post_issue_id_issue_id_fk", + "tableFrom": "post", + "tableTo": "issue", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "post_type_id_post_type_id_fk": { + "name": "post_type_id_post_type_id_fk", + "tableFrom": "post", + "tableTo": "post_type", + "columnsFrom": [ + "type_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "post_cover_media_id_media_id_fk": { + "name": "post_cover_media_id_media_id_fk", + "tableFrom": "post", + "tableTo": "media", + "columnsFrom": [ + "cover_media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "post_project_slug": { + "name": "post_project_slug", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "slug" + ] + }, + "post_project_number": { + "name": "post_project_number", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tag": { + "name": "tag", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "tag_project_id_project_id_fk": { + "name": "tag_project_id_project_id_fk", + "tableFrom": "tag", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tag_project_slug": { + "name": "tag_project_slug", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_client": { + "name": "api_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "client_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'read'" + }, + "scope": { + "name": "scope", + "type": "client_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'customer'" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "can_publish": { + "name": "can_publish", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "api_client_project_id_project_id_fk": { + "name": "api_client_project_id_project_id_fk", + "tableFrom": "api_client", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_client_token_hash_unique": { + "name": "api_client_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_read": { + "name": "post_read", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "external_user_id": { + "name": "external_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "post_read_project_user": { + "name": "post_read_project_user", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "post_read_post_id_post_id_fk": { + "name": "post_read_post_id_post_id_fk", + "tableFrom": "post_read", + "tableTo": "post", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_read_project_id_project_id_fk": { + "name": "post_read_project_id_project_id_fk", + "tableFrom": "post_read", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "post_read_post_id_external_user_id_pk": { + "name": "post_read_post_id_external_user_id_pk", + "columns": [ + "post_id", + "external_user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_idempotency_key": { + "name": "api_idempotency_key", + "schema": "", + "columns": { + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "api_idempotency_key_client_id_api_client_id_fk": { + "name": "api_idempotency_key_client_id_api_client_id_fk", + "tableFrom": "api_idempotency_key", + "tableTo": "api_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_idempotency_key_post_id_post_id_fk": { + "name": "api_idempotency_key_post_id_post_id_fk", + "tableFrom": "api_idempotency_key", + "tableTo": "post", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "api_idempotency_key_client_id_key_pk": { + "name": "api_idempotency_key_client_id_key_pk", + "columns": [ + "client_id", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.audit_actor_type": { + "name": "audit_actor_type", + "schema": "public", + "values": [ + "user", + "client" + ] + }, + "public.project_role": { + "name": "project_role", + "schema": "public", + "values": [ + "moderator" + ] + }, + "public.media_kind": { + "name": "media_kind", + "schema": "public", + "values": [ + "image", + "video" + ] + }, + "public.issue_status": { + "name": "issue_status", + "schema": "public", + "values": [ + "draft", + "scheduled", + "published" + ] + }, + "public.post_audience": { + "name": "post_audience", + "schema": "public", + "values": [ + "internal", + "customer", + "public" + ] + }, + "public.post_locale": { + "name": "post_locale", + "schema": "public", + "values": [ + "de", + "en" + ] + }, + "public.post_status": { + "name": "post_status", + "schema": "public", + "values": [ + "draft", + "review", + "scheduled", + "published", + "archived" + ] + }, + "public.client_mode": { + "name": "client_mode", + "schema": "public", + "values": [ + "read", + "write" + ] + }, + "public.client_scope": { + "name": "client_scope", + "schema": "public", + "values": [ + "internal", + "customer", + "public" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json new file mode 100644 index 0000000..44430dd --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,76 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1785406437281, + "tag": "0000_tiny_madrox", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1785406986554, + "tag": "0001_serious_echo", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1785407042880, + "tag": "0002_glamorous_wild_child", + "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1785414345463, + "tag": "0003_acoustic_sabra", + "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1785418382530, + "tag": "0004_white_groot", + "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1785419585654, + "tag": "0005_eager_magdalene", + "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1785420193716, + "tag": "0006_stiff_living_tribunal", + "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1785421669487, + "tag": "0007_parallel_doctor_doom", + "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1785502259896, + "tag": "0008_true_spot", + "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1785502444366, + "tag": "0009_clean_mac_gargan", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/messages/de.json b/messages/de.json new file mode 100644 index 0000000..76154e4 --- /dev/null +++ b/messages/de.json @@ -0,0 +1,726 @@ +{ + "app": { + "name": "Logbuch", + "tagline": "Was zuletzt passiert ist, pro Projekt." + }, + "header": { + "newEntry": "Eintrag schreiben", + "skipToContent": "Zum Inhalt springen" + }, + "nav": { + "overview": "Übersicht", + "search": "Suchen", + "archive": "Nach Monat", + "sidebar": "Hauptnavigation", + "openMenu": "Menü öffnen", + "closeMenu": "Menü schließen", + "state": "Stand {number}", + "admin": "Verwaltung", + "toArchive": "Zum Logbuch" + }, + "pagination": { + "previous": "Zurück", + "next": "Vor", + "status": "{from} - {to} von {total}" + }, + "search": { + "title": "Suche", + "label": "Suchbegriff", + "placeholder": "Titel und Anreißer durchsuchen", + "submit": "Suchen", + "prompt": "Gib einen Suchbegriff ein.", + "found": "Treffer für „{query}“", + "empty": "Kein Eintrag zu „{query}“ gefunden. Versuch ein einzelnes Wort oder sieh dir die neuesten Einträge an.", + "emptyTitle": "Kein Treffer", + "promptTitle": "Wonach suchst du?", + "intro": "Durchsucht Titel und Anreißer aller veröffentlichten Einträge.", + "suggestions": "Zuletzt erschienen" + }, + "post": { + "fields": { + "project": "Projekt", + "published": "Veröffentlicht", + "type": "Typ", + "author": "Autor", + "images": "Bilder", + "number": "Nummer" + }, + "content": "Inhalt", + "empty": "Dieser Eintrag hat noch keinen Inhalt.", + "back": "Alle Einträge von {project}", + "details": "Angaben", + "readingTime": "{minutes} Min. Lesezeit", + "emptyTitle": "Noch kein Inhalt" + }, + "block": { + "before": "Vorher", + "after": "Nachher", + "video": "Video ansehen", + "image": "Bild", + "gallery": "Galerie", + "open": "Bild vergrößern", + "close": "Schließen", + "previous": "Vorheriges Bild", + "next": "Nächstes Bild", + "position": "{index} von {total}", + "compare": "Vergleich verschieben", + "comparePosition": "{percent} Prozent vorher" + }, + "notFound": { + "title": "Diese Seite steht nicht im Logbuch.", + "hint": "Diese Adresse gibt es nicht, oder der Eintrag ist nicht veröffentlicht.", + "home": "Zur Übersicht", + "next": "Von der Übersicht aus erreichst du jedes Projekt und jeden Eintrag. Wenn du einen bestimmten Eintrag suchst, hilft die Suche weiter.", + "nextTitle": "Was jetzt hilft", + "code": "404" + }, + "theme": { + "label": "Farbmodus wechseln", + "light": "Hell", + "dark": "Dunkel", + "system": "System" + }, + "overview": { + "title": "Was zuletzt passiert ist.", + "intro": "Alle zwei Wochen halten wir pro Projekt fest, was fertig geworden ist. Mit Bildern, in ganzen Sätzen, ohne Ticketnummern-Kauderwelsch.", + "allProjects": "Alle Projekte", + "empty": "Noch ist kein Beitrag veröffentlicht. Leg in der Verwaltung ein Projekt an und schreib den ersten Eintrag.", + "fields": { + "brands": "Flotte", + "period": "Zeitraum", + "entries": "Einträge", + "updated": "Stand" + }, + "entryCount": "{total} gesamt, {recent} in den letzten {days} Tagen", + "period": "{from} - {to}", + "emptyTitle": "Noch nichts veröffentlicht", + "emptyFilteredTitle": "Nichts in diesem Zeitraum", + "emptyFiltered": "In diesem Zeitraum ist kein Eintrag erschienen. Setze die Eingrenzung zurück oder wähle einen anderen Monat.", + "emptyTypeTitle": "Nichts von dieser Art", + "emptyType": "Von dieser Art gibt es hier keinen Eintrag. Wähle eine andere Art oder zeig alle Einträge.", + "updated": "Zuletzt {date}", + "toAdmin": "Zur Verwaltung", + "emptyProject": "In diesem Projekt ist noch kein Eintrag veröffentlicht. Sieh dir die anderen Projekte an oder schreib den ersten Eintrag in der Verwaltung.", + "emptyPageTitle": "Diese Seite gibt es nicht", + "emptyPage": "So weit reicht die Liste nicht. Geh zurück auf die erste Seite.", + "firstPage": "Zur ersten Seite" + }, + "entry": { + "latest": "Zuletzt", + "older": "Ältere Einträge", + "summary": "{count, plural, one {# Eintrag} other {# Einträge}} · {days} Tage", + "new": "Neu", + "read": "gelesen", + "unread": "ungelesen", + "number": "Eintrag {number}", + "images": "{count, plural, one {# Bild} other {# Bilder}}", + "next": "Weitere Einträge" + }, + "archive": { + "title": "Archiv", + "all": "Alle Einträge", + "filtered": "Eingegrenzt auf {period}", + "entries": "{count, plural, one {# Eintrag} other {# Einträge}}", + "months": { + "1": "Jan", + "2": "Feb", + "3": "Mär", + "4": "Apr", + "5": "Mai", + "6": "Jun", + "7": "Jul", + "8": "Aug", + "9": "Sep", + "10": "Okt", + "11": "Nov", + "12": "Dez" + }, + "firstPage": "Zur ersten Seite" + }, + "inapp": { + "title": "In der Anwendung", + "hint": "Dieselben Einträge holt sich jede App über die API und zeigt sie im eigenen Design. Gelesen wird pro Nutzer der jeweiligen App gemerkt.", + "markAllRead": "Alles gelesen", + "open": "Im Logbuch öffnen", + "close": "Schließen" + }, + "footer": { + "state": "Stand {date}" + }, + "login": { + "eyebrow": "Verwaltung", + "title": "Anmelden", + "intro": "Melde dich an, um Projekte anzulegen und Beiträge zu schreiben.", + "email": "E-Mail-Adresse", + "password": "Passwort", + "submit": "Anmelden", + "pending": "Wird geprüft", + "back": "Zurück zum Logbuch", + "noAccount": "Konten legt die Verwaltung an. Wenn du noch keins hast, frag die Person, die das Logbuch betreut.", + "errors": { + "email": "Trag deine E-Mail-Adresse ein, so wie sie beim Konto hinterlegt ist.", + "password": "Trag dein Passwort ein.", + "credentials": "E-Mail-Adresse und Passwort passen nicht zusammen. Prüfe beides und versuch es noch einmal.", + "tooMany": "Zu viele Versuche kurz hintereinander. Warte eine Minute und versuch es dann erneut.", + "unknown": "Die Anmeldung hat gerade nicht geklappt. Versuch es in einem Moment noch einmal." + }, + "magic": { + "title": "Mit Anmeldelink", + "intro": "Trag deine Adresse ein. Du bekommst einen Link per E-Mail, mit dem du dich ohne Passwort anmeldest.", + "email": "E-Mail-Adresse", + "submit": "Link schicken", + "pending": "Wird geschickt", + "sentTitle": "Der Link ist unterwegs", + "sent": "Wenn {email} sich anmelden darf, liegt gleich eine E-Mail mit dem Anmeldelink im Postfach.", + "sentHint": "Der Link gilt {minutes} Minuten und lässt sich einmal verwenden. Wenn nichts ankommt, sieh im Spam-Ordner nach.", + "again": "Andere Adresse verwenden", + "passwordTitle": "Lieber mit Passwort anmelden", + "linkFailed": "Dieser Anmeldelink gilt nicht mehr. Er läuft nach {minutes} Minuten ab und lässt sich nur einmal verwenden. Fordere einen neuen Link an.", + "errors": { + "email": "Trag eine vollständige E-Mail-Adresse ein, zum Beispiel name@nyo.de.", + "tooMany": "Für diese Adresse wurden gerade zu viele Links angefordert. Warte {minutes} Minuten und versuch es dann noch einmal." + } + } + }, + "admin": { + "eyebrow": "Verwaltung", + "title": "Verwaltung", + "intro": "Hier legst du Projekte an und schreibst Beiträge.", + "signedInAs": "Angemeldet als {name}", + "signOut": "Abmelden", + "toArchive": "Zum Logbuch", + "projects": { + "eyebrow": "Verwaltung", + "title": "Projekte", + "intro": "Ein Projekt ist ein eigener Bereich im Logbuch, mit eigener Farbe, eigenem Kürzel und eigener Nummernreihe.", + "create": "Projekt anlegen", + "createTitle": "Neues Projekt", + "createIntro": "Name und Marke reichen zum Anfangen. Der Slug wird aus dem Namen vorgeschlagen und bleibt änderbar.", + "editIntro": "Änderungen gelten sofort, auch im Archiv.", + "save": "Speichern", + "saving": "Wird gespeichert", + "back": "Zurück zur Liste", + "toArchive": "Im Archiv ansehen", + "active": "Aktiv", + "inactive": "Stillgelegt", + "emptyTitle": "Noch kein Projekt", + "empty": "Es gibt noch kein Projekt. Lege das erste an, dann kannst du Beiträge und Bilder dafür anlegen.", + "noBrandsTitle": "Zuerst eine Marke", + "noBrands": "Ein Projekt gehört immer zu einer Marke. Lege zuerst eine Marke an, dann kannst du das Projekt anlegen.", + "toBrands": "Marke anlegen", + "columns": { + "name": "Name", + "code": "Kürzel", + "brand": "Marke", + "brandMissing": "Unbekannt", + "color": "Farbe", + "posts": "Beiträge", + "state": "Status", + "edit": "Bearbeiten" + }, + "fields": { + "name": "Name", + "slug": "Slug", + "code": "Kürzel", + "brand": "Marke", + "brandEmpty": "Marke wählen", + "color": "Farbe", + "colorPicker": "Farbe auswählen", + "sort": "Sortierung", + "description": "Beschreibung", + "active": "Projekt ist aktiv" + }, + "hints": { + "slug": "Teil der Adresse im Archiv, zum Beispiel /trakk. Kleinbuchstaben, Ziffern und Bindestriche.", + "code": "Steht auf der Kennplatte, höchstens vier Zeichen. Leer lassen, dann wird es aus dem Slug abgeleitet.", + "color": "Trägt Kennplatte und Balken. Als Hexwert, zum Beispiel #2e7d5b.", + "sort": "Kleinere Zahlen stehen weiter oben.", + "description": "Ein bis zwei Sätze, wofür das Projekt steht.", + "active": "Ein stillgelegtes Projekt erscheint weder im Archiv noch in der API." + } + }, + "projectsMeta": "{count, plural, =0 {kein Projekt} one {# Projekt} other {# Projekte}}", + "emptyTitle": "Noch kein Projekt", + "empty": "Für dich ist noch kein Projekt freigeschaltet oder es ist noch keins angelegt.", + "role": { + "admin": "Verwaltung", + "moderator": "Moderator" + }, + "noAccess": { + "title": "Noch nicht freigeschaltet", + "forNobody": "Dein Konto gibt es, aber es ist für kein Projekt freigeschaltet. Bitte die Person, die das Logbuch betreut, dich als Moderator für dein Projekt einzutragen.", + "forModerator": "Dieser Teil der Verwaltung ist Konten mit Verwaltungsrechten vorbehalten. Deine Projekte findest du auf der Übersicht.", + "toOverview": "Zur Übersicht", + "wrongAccount": "Falsches Konto? Melde dich ab und mit dem richtigen wieder an." + }, + "yourProjects": "Deine Projekte", + "edit": "Bearbeiten", + "inactive": "Stillgelegt", + "emptyForAdmin": "Es gibt noch kein Projekt. Lege eins an, dann kannst du Beiträge und Bilder dafür anlegen.", + "emptyAction": "Projekt anlegen", + "nav": { + "label": "Bereiche der Verwaltung", + "overview": "Übersicht", + "posts": "Beiträge", + "media": "Medien", + "projects": "Projekte", + "brands": "Marken", + "users": "Benutzer", + "clients": "Zugänge", + "manage": "Verwalten", + "postTypes": "Beitragsarten", + "api": "API" + }, + "messages": { + "created": "Angelegt.", + "updated": "Gespeichert.", + "forbidden": "Dafür fehlt deinem Konto das Recht. Nur die Verwaltung darf das ändern.", + "notFound": "Der Datensatz ist nicht mehr da. Lade die Seite neu.", + "invalid": "Bitte prüfe die markierten Felder.", + "slugTaken": "Diesen Slug gibt es schon. Wähle einen anderen.", + "selfAdmin": "Das eigene Verwaltungsrecht kannst du nicht ändern. Das muss ein anderes Verwaltungskonto tun.", + "typeInUse": "Diese Art wird noch benutzt. Nimm stattdessen den Haken bei Aktiv heraus." + }, + "errors": { + "nameRequired": "Trag einen Namen ein.", + "slugInvalid": "Der Slug besteht aus Kleinbuchstaben, Ziffern und Bindestrichen, mindestens zwei Zeichen.", + "slugTaken": "Dieser Slug ist vergeben. Wähle einen anderen.", + "codeInvalid": "Das Kürzel hat höchstens vier Zeichen, nur Buchstaben und Ziffern.", + "colorInvalid": "Die Farbe braucht die Form #rrggbb.", + "brandUnknown": "Wähle eine Marke aus.", + "projectUnknown": "Wähle ein Projekt aus oder lass den Zugang für alle Projekte gelten.", + "sortInvalid": "Die Sortierung ist eine ganze Zahl von 0 bis 9999.", + "modeInvalid": "Wähle lesen oder schreiben.", + "scopeInvalid": "Wähle eine Zielgruppe.", + "tooLong": "Der Text ist zu lang.", + "invalid": "Dieser Wert passt nicht." + }, + "entries": { + "eyebrow": "Verwaltung", + "title": "Beiträge", + "intro": "Titel eintippen, losschreiben, veröffentlichen. Projekt, Art und Zielgruppe sind vorbelegt.", + "listTitle": "Alle Beiträge", + "create": "Neuer Beitrag", + "createTitle": "Neuer Beitrag", + "editTitle": "Beitrag", + "back": "Zur Liste", + "meta": "{count, plural, =0 {kein Beitrag} one {# Beitrag} other {# Beiträge}}", + "noAuthor": "ohne Autor", + "emptyTitle": "Noch kein Beitrag", + "empty": "In deinen Projekten steht noch kein Beitrag. Schreib den ersten, das dauert keine zwei Minuten.", + "emptyFilteredTitle": "Kein Treffer", + "emptyFiltered": "Zu dieser Eingrenzung gibt es keinen Beitrag. Setz die Eingrenzung zurück oder ändere sie.", + "noProjectsTitle": "Noch kein Projekt", + "noProjects": "Für dich ist noch kein Projekt freigeschaltet. Bitte die Verwaltung, dich als Moderator einzutragen.", + "noProjectsForAdmin": "Es gibt noch kein Projekt. Lege eins an, dann kannst du Beiträge dafür schreiben.", + "toProjects": "Projekt anlegen", + "columns": { + "number": "Nummer", + "title": "Titel", + "project": "Projekt", + "type": "Art", + "audience": "Zielgruppe", + "status": "Status", + "date": "Datum", + "author": "Autor" + }, + "pagination": { + "previous": "Zurück", + "next": "Vor", + "status": "Seite {page} von {pages}" + }, + "filters": { + "project": "Projekt", + "allProjects": "Alle Projekte", + "status": "Status", + "allStatus": "Jeder Status", + "type": "Art", + "allTypes": "Alle Arten", + "search": "Suche", + "searchPlaceholder": "Titel und Anreißer", + "submit": "Anwenden", + "reset": "Eingrenzung zurücksetzen" + }, + "status": { + "draft": "Entwurf", + "review": "Prüfung", + "scheduled": "Geplant", + "published": "Veröffentlicht", + "archived": "Zurückgezogen" + }, + "audience": { + "internal": "Intern", + "customer": "Kunde", + "public": "Öffentlich" + }, + "fields": { + "title": "Titel", + "titlePlaceholder": "Worum geht es?", + "teaser": "Anreißer", + "teaserPlaceholder": "Zwei Sätze, die den Beitrag zusammenfassen.", + "project": "Projekt", + "type": "Art", + "audience": "Zielgruppe", + "slug": "Slug", + "cover": "Aufmacherbild" + }, + "hints": { + "audience": "Kunde sieht keine internen Beiträge, öffentlich sehen alle.", + "teaser": "Steht in der Liste und im Aufmacher. Leer lassen geht auch.", + "slug": "Teil der Adresse im Archiv. Leer lassen, dann wird er aus dem Titel gebildet.", + "cover": "Steht groß über dem Beitrag. Ohne Bild trägt die Kennplatte den Platz.", + "drop": "Bilder hierher ziehen oder mit Strg+V einfügen, auch mehrere auf einmal.", + "schedule": "Ein Termin in der Zukunft. Der Beitrag erscheint automatisch, sobald er erreicht ist.", + "projectMove": "Beim Wechsel bekommt der Beitrag die nächste freie Nummer im Zielprojekt." + }, + "save": { + "idle": "Noch nichts gespeichert", + "pending": "Wird gespeichert", + "dirty": "Nicht gespeicherte Änderungen", + "saved": "Gespeichert um {time}", + "stored": "Zuletzt gespeichert {time}" + }, + "schedule": { + "title": "Termin" + }, + "actions": { + "save": "Speichern", + "publish": "Veröffentlichen", + "publishing": "Wird veröffentlicht", + "schedule": "Termin setzen", + "unschedule": "Termin zurücknehmen", + "archive": "Zurückziehen", + "resume": "Wieder als Entwurf", + "preview": "Vorschau", + "edit": "Bearbeiten", + "addBlock": "Block hinzufügen", + "remove": "Block entfernen", + "up": "Nach oben", + "down": "Nach unten", + "drag": "Block verschieben", + "choose": "Bild wählen", + "change": "Anderes Bild", + "clear": "Bild entfernen", + "upload": "Hochladen", + "close": "Schließen" + }, + "blocks": { + "text": "Text", + "image": "Bild", + "gallery": "Galerie", + "before_after": "Vorher und nachher", + "video": "Video", + "quote": "Zitat", + "code": "Code", + "link": "Verweis", + "callout": "Hinweis" + }, + "blockFields": { + "text": "Text", + "textPlaceholder": "Schreib in ganzen Sätzen. Eine Leerzeile trennt Absätze.", + "image": "Bild", + "gallery": "Bilder", + "galleryHint": "Die Reihenfolge entspricht der Auswahl.", + "before": "Vorher", + "after": "Nachher", + "video": "Videodatei", + "videoHint": "Ohne Datei genügt ein Verweis auf das Video.", + "url": "Adresse", + "label": "Beschriftung", + "description": "Beschreibung", + "quote": "Zitat", + "source": "Quelle", + "language": "Sprache", + "code": "Code", + "tone": "Ton", + "toneInfo": "Hinweis", + "toneWarning": "Warnung" + }, + "media": { + "empty": "In diesem Projekt liegt noch kein Bild. Lade eins hoch oder zieh es in den Editor.", + "altMissing": "ohne Alt-Text" + }, + "uploads": { + "pending": "{name} wird hochgeladen", + "done": "{name} ist hochgeladen", + "failed": "{name}: {reason}" + }, + "preview": { + "scope": "Ansicht als", + "untitled": "Ohne Titel", + "empty": "Dieser Beitrag hat noch keinen Inhalt.", + "hiddenTitle": "Für diese Zielgruppe unsichtbar", + "hidden": "Als {scope} ist dieser Beitrag nicht zu sehen, er ist für {audience} bestimmt." + }, + "checks": { + "blockersTitle": "Das hält das Veröffentlichen auf", + "title_missing": "Der Titel fehlt.", + "content_empty": "Der Beitrag hat noch keinen Inhalt.", + "alt_text_missing": "Ein Bild hat keinen Alt-Text. Bei öffentlichen Beiträgen ist er Pflicht.", + "tags_missing": "Noch kein Schlagwort vergeben.", + "cover_missing": "Noch kein Aufmacherbild gesetzt." + }, + "errors": { + "forbidden": "Dafür fehlt deinem Konto das Recht.", + "projectUnknown": "Dieses Projekt gibt es nicht.", + "typeUnknown": "Diese Beitragsart steht nicht mehr zur Auswahl. Wähle eine andere.", + "projectLocked": "Das Projekt lässt sich nach dem Anlegen nicht mehr wechseln.", + "notFound": "Diesen Beitrag gibt es nicht mehr. Lade die Seite neu.", + "titleMissing": "Trag zuerst einen Titel ein.", + "titleTooLong": "Der Titel ist zu lang.", + "teaserTooLong": "Der Anreißer ist zu lang.", + "slugInvalid": "Der Slug besteht aus Kleinbuchstaben, Ziffern und Bindestrichen, mindestens zwei Zeichen.", + "slugTaken": "Diesen Slug gibt es in diesem Projekt schon.", + "blocksInvalid": "Ein Block ist unvollständig. Prüfe die Adressen und die Bilder.", + "blocked": "Veröffentlichen geht noch nicht.", + "statusInvalid": "Dieser Schritt ist aus dem aktuellen Status nicht möglich.", + "scheduleMissing": "Wähle einen Termin aus.", + "schedulePast": "Der Termin muss in der Zukunft liegen.", + "uploadFailed": "Das Hochladen hat gerade nicht geklappt. Versuch es in einem Moment noch einmal.", + "fileMissing": "Wähle eine Bilddatei aus.", + "fileTooLarge": "Die Datei ist größer als 15 MB. Verkleinere sie und versuch es noch einmal.", + "unsupportedType": "Dieses Format wird nicht angenommen. Nimm JPEG, PNG, WebP, AVIF, GIF oder TIFF.", + "unreadableImage": "Die Datei lässt sich nicht als Bild lesen. Prüfe, ob sie vollständig ist.", + "unknown": "Das hat gerade nicht geklappt. Versuch es noch einmal." + } + }, + "brands": { + "eyebrow": "Verwaltung", + "title": "Marken", + "intro": "Marken fassen Projekte zusammen, zum Beispiel nach Firma. Jedes Projekt gehört zu genau einer Marke.", + "create": "Marke anlegen", + "createTitle": "Neue Marke", + "createIntro": "Name und Farbe genügen. Der Slug wird aus dem Namen vorgeschlagen.", + "editIntro": "Änderungen gelten sofort für alle Projekte dieser Marke.", + "save": "Speichern", + "saving": "Wird gespeichert", + "back": "Zurück zur Liste", + "emptyTitle": "Noch keine Marke", + "empty": "Es gibt noch keine Marke. Lege die erste an, danach kannst du Projekte anlegen.", + "columns": { + "name": "Name", + "slug": "Slug", + "color": "Farbe", + "projects": "Projekte", + "sort": "Sortierung", + "edit": "Bearbeiten" + }, + "fields": { + "name": "Name", + "slug": "Slug", + "color": "Farbe", + "colorPicker": "Farbe auswählen", + "sort": "Sortierung" + }, + "hints": { + "slug": "Kleinbuchstaben, Ziffern und Bindestriche.", + "color": "Als Hexwert, zum Beispiel #2b4a9b.", + "sort": "Kleinere Zahlen stehen weiter oben." + } + }, + "postTypes": { + "eyebrow": "Verwaltung", + "title": "Beitragsarten", + "intro": "Beitragsarten kennzeichnen, worum es in einem Beitrag geht. Schlüssel, Bezeichnung, Farbe und Reihenfolge legst du hier fest.", + "create": "Beitragsart anlegen", + "createTitle": "Neue Beitragsart", + "createIntro": "Bezeichnung und Farbe genügen. Der Schlüssel wird aus der deutschen Bezeichnung vorgeschlagen.", + "editIntro": "Änderungen gelten sofort für alle Beiträge dieser Art.", + "save": "Speichern", + "saving": "Wird gespeichert", + "back": "Zurück zur Liste", + "emptyTitle": "Noch keine Beitragsart", + "empty": "Es gibt noch keine Beitragsart. Lege die erste an, danach kannst du Beiträge einordnen.", + "active": "Aktiv", + "inactive": "Stillgelegt", + "usage": "{count, plural, =0 {kein Beitrag} one {# Beitrag} other {# Beiträge}}", + "deleteTitle": "Löschen", + "delete": "Beitragsart löschen", + "deleting": "Wird gelöscht", + "deleteBlocked": "Diese Art wird von {count, plural, one {# Beitrag} other {# Beiträgen}} benutzt und lässt sich nicht löschen. Nimm den Haken bei Aktiv heraus, dann steht sie nicht mehr zur Auswahl.", + "columns": { + "label": "Bezeichnung", + "key": "Schlüssel", + "color": "Farbe", + "posts": "Beiträge", + "sort": "Sortierung", + "active": "Aktiv", + "edit": "Bearbeiten" + }, + "fields": { + "labelDe": "Bezeichnung deutsch", + "labelEn": "Bezeichnung englisch", + "key": "Schlüssel", + "color": "Farbe", + "colorPicker": "Farbe auswählen", + "sort": "Sortierung", + "isActive": "Aktiv" + }, + "hints": { + "key": "Kleinbuchstaben, Ziffern und Bindestriche. Steht so in der API.", + "color": "Als Hexwert, zum Beispiel #2b4a9b. Damit wird die Art am Beitrag gekennzeichnet.", + "sort": "Kleinere Zahlen stehen weiter vorn, in der Auswahl und in den Filtern.", + "isActive": "Nur aktive Arten stehen beim Schreiben zur Auswahl.", + "activeInUse": "Nur aktive Arten stehen beim Schreiben zur Auswahl. Beiträge, die diese Art schon tragen, behalten sie." + } + }, + "users": { + "eyebrow": "Verwaltung", + "title": "Benutzer", + "intro": "Ein Konto braucht nur, wer schreibt. Lesen geht ohne Konto.", + "columns": { + "name": "Name", + "email": "E-Mail", + "role": "Recht", + "projects": "Projekte", + "edit": "Öffnen" + }, + "roles": { + "admin": "Verwaltung", + "moderator": "Moderator" + }, + "you": "du", + "all": "alle", + "back": "Zurück zur Liste", + "saving": "Wird gespeichert", + "emptyTitle": "Noch kein Konto", + "empty": "Es gibt noch kein Konto. Leg eins mit dem Befehl pnpm admin:create an.", + "newAccountTitle": "Neues Konto", + "newAccount": "Konten entstehen über den Befehl pnpm admin:create. Danach vergibst du hier die Rechte.", + "adminSection": "Verwaltungsrecht", + "isAdmin": "Dieses Konto verwaltet alles und hat Zugriff auf jedes Projekt.", + "isNotAdmin": "Dieses Konto arbeitet nur in den Projekten, für die es freigeschaltet ist.", + "grantAdmin": "Verwaltungsrecht geben", + "revokeAdmin": "Verwaltungsrecht entziehen", + "selfHint": "Am eigenen Konto lässt sich das Verwaltungsrecht nicht ändern.", + "rolesSection": "Projekte", + "assignedMeta": "{count, plural, =0 {kein Projekt} one {# Projekt} other {# Projekte}}", + "adminHasAll": "Das Konto hat Verwaltungsrecht und damit ohnehin Zugriff auf jedes Projekt. Die Freischaltungen hier greifen, sobald das Verwaltungsrecht entzogen wird.", + "grantRole": "Freischalten", + "revokeRole": "Entziehen", + "implicit": "über Verwaltungsrecht", + "rolesHint": "Ein Moderator legt Beiträge und Bilder seiner Projekte an und veröffentlicht sie.", + "noProjectsTitle": "Noch kein Projekt", + "noProjects": "Es gibt noch kein Projekt, das du freischalten könntest.", + "toProjects": "Projekt anlegen", + "accountSection": "Konto", + "accountHint": "Name, E-Mail und Passwort setzt der Befehl pnpm admin:create." + }, + "clients": { + "eyebrow": "Verwaltung", + "title": "API-Zugänge", + "intro": "Anwendungen holen Beiträge über einen Zugang. Lesende Zugänge liefern veröffentlichte Beiträge ihrer Zielgruppe, schreibende legen Entwürfe an.", + "listTitle": "Zugänge", + "meta": "{count, plural, =0 {kein Zugang} one {# Zugang} other {# Zugänge}}", + "emptyTitle": "Noch kein Zugang", + "empty": "Es gibt noch keinen Zugang. Leg unten einen an, damit eine Anwendung Beiträge abrufen kann.", + "columns": { + "name": "Name", + "mode": "Modus", + "scope": "Zielgruppe", + "project": "Projekt", + "lastUsed": "Zuletzt genutzt", + "state": "Status", + "action": "Aktion" + }, + "modes": { + "read": "Lesen", + "write": "Schreiben" + }, + "scopes": { + "internal": "Intern", + "customer": "Kunde", + "public": "Öffentlich" + }, + "allProjects": "Alle Projekte", + "unknownProject": "Unbekannt", + "never": "noch nie", + "activeState": "Aktiv", + "revoked": "Widerrufen", + "revoke": "Widerrufen", + "revoking": "Wird widerrufen", + "createTitle": "Zugang anlegen", + "createIntro": "Das Token wird einmal angezeigt und nur als Hash gespeichert. Wer es verliert, legt einen neuen Zugang an und widerruft den alten.", + "create": "Zugang anlegen", + "creating": "Wird angelegt", + "tokenTitle": "Token, nur jetzt sichtbar", + "tokenHint": "Kopier das Token jetzt und trag es in der Anwendung ein. Später lässt es sich nicht mehr anzeigen.", + "copy": "Token kopieren", + "copied": "Kopiert", + "fields": { + "name": "Name", + "mode": "Modus", + "scope": "Zielgruppe", + "project": "Projekt" + }, + "hints": { + "name": "Woran du diesen Zugang später erkennst, zum Beispiel Trakk Web.", + "mode": "Lesen holt Beiträge. Schreiben legt Entwürfe an und lädt Bilder hoch.", + "scope": "Bestimmt, welche Beiträge der Zugang sieht. Kunde bekommt nie interne Beiträge.", + "project": "Ohne Bindung gilt der Zugang für alle Projekte." + }, + "publishTitle": "Veröffentlichen", + "publishHint": "Kein Zugang darf veröffentlichen. Das bleibt an Konten gebunden." + } + }, + "filter": { + "type": "Art", + "allTypes": "Alle Arten" + }, + "media": { + "eyebrow": "Bilder", + "title": "Mediathek", + "intro": "Pro Projekt ein Bildbestand. Beim Hochladen entstehen automatisch WebP-Fassungen in mehreren Breiten.", + "back": "Alle Projekte", + "library": "Bestand", + "projectIntro": "Lade Bilder hoch und pflege Alt-Text und Bildunterschrift. Ohne Alt-Text lässt sich ein öffentlicher Beitrag nicht veröffentlichen.", + "projectMeta": "{count, plural, =0 {kein Bild} one {# Bild} other {# Bilder}}", + "emptyProjectsTitle": "Noch kein Projekt", + "emptyProjects": "Für dich ist noch kein Projekt freigeschaltet oder es ist noch keins angelegt.", + "emptyLibraryTitle": "Noch kein Bild", + "emptyLibrary": "In diesem Projekt liegt noch kein Bild. Lade das erste über das Feld oben hoch.", + "upload": { + "title": "Bild hochladen", + "hint": "JPEG, PNG, WebP, AVIF, GIF oder TIFF. Das Original bleibt erhalten, die Fassungen entstehen daraus.", + "file": "Datei", + "submit": "Hochladen", + "pending": "Wird verarbeitet", + "limit": "Höchstens 15 MB" + }, + "fields": { + "alt": "Alt-Text", + "altHint": "Bei öffentlichen Beiträgen Pflicht, sonst eine Empfehlung.", + "altPlaceholder": "Was ist auf dem Bild zu sehen?", + "caption": "Bildunterschrift" + }, + "meta": { + "submit": "Speichern", + "pending": "Wird gespeichert" + }, + "card": { + "meta": "{width} × {height} · {size} KB · {variants, plural, =0 {keine Fassung} one {# Fassung} other {# Fassungen}}", + "altMissing": "Ohne Alt-Text. Für öffentliche Beiträge ist er Pflicht.", + "variantError": "Die Fassungen konnten nicht erzeugt werden. Das Original ist gespeichert und nutzbar." + }, + "messages": { + "uploaded": "Das Bild ist hochgeladen und die Fassungen sind erzeugt.", + "saved": "Alt-Text und Bildunterschrift sind gespeichert.", + "fileMissing": "Wähle eine Bilddatei aus.", + "fileTooLarge": "Die Datei ist größer als 15 MB. Verkleinere sie und versuch es noch einmal.", + "unsupportedType": "Dieses Format wird nicht angenommen. Nimm JPEG, PNG, WebP, AVIF, GIF oder TIFF.", + "unreadableImage": "Die Datei lässt sich nicht als Bild lesen. Prüfe, ob sie vollständig ist.", + "projectUnknown": "Dieses Projekt gibt es nicht.", + "mediaUnknown": "Dieses Bild gibt es nicht mehr.", + "uploadFailed": "Das Hochladen hat gerade nicht geklappt. Versuch es in einem Moment noch einmal." + } + }, + "mail": { + "magicLink": { + "subject": "Dein Anmeldelink für das Logbuch", + "greeting": "Hallo,", + "body": "hier ist dein Anmeldelink für das Logbuch. Er gilt {minutes} Minuten und lässt sich einmal verwenden.", + "ignore": "Wenn du keinen Link angefordert hast, ignoriere diese Nachricht. Ohne den Link passiert nichts.", + "signature": "Logbuch" + } + } +} diff --git a/messages/en.json b/messages/en.json new file mode 100644 index 0000000..5977437 --- /dev/null +++ b/messages/en.json @@ -0,0 +1,726 @@ +{ + "app": { + "name": "Logbuch", + "tagline": "What happened lately, per project." + }, + "header": { + "newEntry": "Write entry", + "skipToContent": "Skip to content" + }, + "nav": { + "overview": "Overview", + "search": "Search", + "archive": "By month", + "sidebar": "Main navigation", + "openMenu": "Open menu", + "closeMenu": "Close menu", + "state": "State {number}", + "admin": "Admin", + "toArchive": "To the log" + }, + "pagination": { + "previous": "Back", + "next": "Next", + "status": "{from} - {to} of {total}" + }, + "search": { + "title": "Search", + "label": "Search term", + "placeholder": "Search titles and teasers", + "submit": "Search", + "prompt": "Enter a search term.", + "found": "Matches for “{query}”", + "empty": "No entry found for “{query}”. Try a single word or take a look at the latest entries.", + "emptyTitle": "No match", + "promptTitle": "What are you looking for?", + "intro": "Searches titles and teasers of all published entries.", + "suggestions": "Published lately" + }, + "post": { + "fields": { + "project": "Project", + "published": "Published", + "type": "Type", + "author": "Author", + "images": "Images", + "number": "Number" + }, + "content": "Content", + "empty": "This entry has no content yet.", + "back": "All entries of {project}", + "details": "Details", + "readingTime": "{minutes} min read", + "emptyTitle": "No content yet" + }, + "block": { + "before": "Before", + "after": "After", + "video": "Watch video", + "image": "Image", + "gallery": "Gallery", + "open": "Enlarge image", + "close": "Close", + "previous": "Previous image", + "next": "Next image", + "position": "{index} of {total}", + "compare": "Move the comparison", + "comparePosition": "{percent} percent before" + }, + "notFound": { + "title": "This page is not in the logbook.", + "hint": "This address does not exist, or the entry is not published.", + "home": "To the overview", + "next": "From the overview you can reach every project and every entry. If you are looking for a specific entry, the search will help.", + "nextTitle": "What helps now", + "code": "404" + }, + "theme": { + "label": "Change colour mode", + "light": "Light", + "dark": "Dark", + "system": "System" + }, + "overview": { + "title": "What happened lately.", + "intro": "Every two weeks we write down per project what has been finished. With images, in full sentences, without ticket number gibberish.", + "allProjects": "All projects", + "empty": "No entry published yet. Create a project in the administration and write the first entry.", + "fields": { + "brands": "Fleet", + "period": "Period", + "entries": "Entries", + "updated": "Updated" + }, + "entryCount": "{total} in total, {recent} within the last {days} days", + "period": "{from} - {to}", + "emptyTitle": "Nothing published yet", + "emptyFilteredTitle": "Nothing in this period", + "emptyFiltered": "No entry appeared in this period. Reset the filter or pick another month.", + "emptyTypeTitle": "Nothing of this kind", + "emptyType": "There is no entry of this kind here. Pick another kind or show all entries.", + "updated": "Last {date}", + "toAdmin": "To the administration", + "emptyProject": "No entry published in this project yet. Take a look at the other projects or write the first entry in the administration.", + "emptyPageTitle": "This page does not exist", + "emptyPage": "The list does not reach that far. Go back to the first page.", + "firstPage": "To the first page" + }, + "entry": { + "latest": "Latest", + "older": "Older entries", + "summary": "{count, plural, one {# entry} other {# entries}} · {days} days", + "new": "New", + "read": "read", + "unread": "unread", + "number": "Entry {number}", + "images": "{count, plural, one {# image} other {# images}}", + "next": "More entries" + }, + "archive": { + "title": "Archive", + "all": "All entries", + "filtered": "Narrowed down to {period}", + "entries": "{count, plural, one {# entry} other {# entries}}", + "months": { + "1": "Jan", + "2": "Feb", + "3": "Mar", + "4": "Apr", + "5": "May", + "6": "Jun", + "7": "Jul", + "8": "Aug", + "9": "Sep", + "10": "Oct", + "11": "Nov", + "12": "Dec" + }, + "firstPage": "To the first page" + }, + "inapp": { + "title": "Inside the application", + "hint": "Every app fetches the same entries through the API and renders them in its own design. Read state is kept per user of the respective app.", + "markAllRead": "Mark all read", + "open": "Open in Logbuch", + "close": "Close" + }, + "footer": { + "state": "Updated {date}" + }, + "login": { + "eyebrow": "Administration", + "title": "Sign in", + "intro": "Sign in to create projects and write entries.", + "email": "Email address", + "password": "Password", + "submit": "Sign in", + "pending": "Checking", + "back": "Back to Logbuch", + "noAccount": "Accounts are created by the administration. If you do not have one yet, ask the person who looks after Logbuch.", + "errors": { + "email": "Enter your email address exactly as it is stored on the account.", + "password": "Enter your password.", + "credentials": "Email address and password do not match. Check both and try again.", + "tooMany": "Too many attempts in a row. Wait a minute and try again.", + "unknown": "Signing in did not work just now. Try again in a moment." + }, + "magic": { + "title": "With a sign-in link", + "intro": "Enter your address. You will receive a link by email that signs you in without a password.", + "email": "Email address", + "submit": "Send link", + "pending": "Sending", + "sentTitle": "The link is on its way", + "sent": "If {email} is allowed to sign in, an email with the sign-in link will arrive shortly.", + "sentHint": "The link is valid for {minutes} minutes and can be used once. If nothing arrives, check the spam folder.", + "again": "Use a different address", + "passwordTitle": "Sign in with a password instead", + "linkFailed": "This sign-in link is no longer valid. It expires after {minutes} minutes and can only be used once. Request a new link.", + "errors": { + "email": "Enter a complete email address, for example name@nyo.de.", + "tooMany": "Too many links have just been requested for this address. Wait {minutes} minutes and try again." + } + } + }, + "admin": { + "eyebrow": "Administration", + "title": "Administration", + "intro": "This is where you create projects and write entries.", + "signedInAs": "Signed in as {name}", + "signOut": "Sign out", + "toArchive": "To Logbuch", + "projects": { + "eyebrow": "Administration", + "title": "Projects", + "intro": "A project is its own section in Logbuch, with its own colour, code and running number.", + "create": "Create project", + "createTitle": "New project", + "createIntro": "Name and brand are enough to start. The slug is suggested from the name and stays editable.", + "editIntro": "Changes take effect right away, in the archive as well.", + "save": "Save", + "saving": "Saving", + "back": "Back to the list", + "toArchive": "View in the archive", + "active": "Active", + "inactive": "Retired", + "emptyTitle": "No project yet", + "empty": "There is no project yet. Create the first one, then you can add entries and images for it.", + "noBrandsTitle": "A brand first", + "noBrands": "A project always belongs to a brand. Create a brand first, then you can create the project.", + "toBrands": "Create brand", + "columns": { + "name": "Name", + "code": "Code", + "brand": "Brand", + "brandMissing": "Unknown", + "color": "Colour", + "posts": "Entries", + "state": "State", + "edit": "Edit" + }, + "fields": { + "name": "Name", + "slug": "Slug", + "code": "Code", + "brand": "Brand", + "brandEmpty": "Pick a brand", + "color": "Colour", + "colorPicker": "Pick a colour", + "sort": "Sorting", + "description": "Description", + "active": "Project is active" + }, + "hints": { + "slug": "Part of the archive address, for example /trakk. Lower case letters, digits and hyphens.", + "code": "Shown on the plate, at most four characters. Leave empty to derive it from the slug.", + "color": "Carries plate and bar. As a hex value, for example #2e7d5b.", + "sort": "Smaller numbers come first.", + "description": "One or two sentences on what the project is about.", + "active": "A retired project shows up neither in the archive nor in the API." + } + }, + "projectsMeta": "{count, plural, =0 {no project} one {# project} other {# projects}}", + "emptyTitle": "No project yet", + "empty": "No project has been released for you yet, or none has been created.", + "role": { + "admin": "Administration", + "moderator": "Moderator" + }, + "noAccess": { + "title": "Not released yet", + "forNobody": "Your account exists, but it is not released for any project. Ask the person who looks after Logbuch to add you as a moderator for your project.", + "forModerator": "This part of the administration is reserved for accounts with administration rights. You find your projects on the overview.", + "toOverview": "To the overview", + "wrongAccount": "Wrong account? Sign out and sign in with the right one." + }, + "yourProjects": "Your projects", + "edit": "Edit", + "inactive": "Retired", + "emptyForAdmin": "There is no project yet. Create one, then you can add entries and images for it.", + "emptyAction": "Create project", + "nav": { + "label": "Administration sections", + "overview": "Overview", + "posts": "Entries", + "media": "Media", + "projects": "Projects", + "brands": "Brands", + "users": "Users", + "clients": "Access", + "manage": "Manage", + "postTypes": "Post types", + "api": "API" + }, + "messages": { + "created": "Created.", + "updated": "Saved.", + "forbidden": "Your account is missing the right for this. Only administration may change it.", + "notFound": "That record is gone. Reload the page.", + "invalid": "Please check the marked fields.", + "slugTaken": "That slug already exists. Pick another one.", + "selfAdmin": "You cannot change the administration right on your own account. Another administration account has to do it.", + "typeInUse": "This type is still in use. Clear the active checkbox instead." + }, + "errors": { + "nameRequired": "Enter a name.", + "slugInvalid": "A slug is made of lower case letters, digits and hyphens, at least two characters.", + "slugTaken": "That slug is taken. Pick another one.", + "codeInvalid": "The code holds at most four characters, letters and digits only.", + "colorInvalid": "The colour needs the form #rrggbb.", + "brandUnknown": "Pick a brand.", + "projectUnknown": "Pick a project or let the access cover all projects.", + "sortInvalid": "Sorting is a whole number from 0 to 9999.", + "modeInvalid": "Pick read or write.", + "scopeInvalid": "Pick an audience.", + "tooLong": "That text is too long.", + "invalid": "That value does not fit." + }, + "entries": { + "eyebrow": "Administration", + "title": "Entries", + "intro": "Type a title, start writing, publish. Project, type and audience come preset.", + "listTitle": "All entries", + "create": "New entry", + "createTitle": "New entry", + "editTitle": "Entry", + "back": "To the list", + "meta": "{count, plural, =0 {no entry} one {# entry} other {# entries}}", + "noAuthor": "no author", + "emptyTitle": "No entry yet", + "empty": "There is no entry in your projects yet. Write the first one, it takes less than two minutes.", + "emptyFilteredTitle": "No match", + "emptyFiltered": "No entry matches this filter. Reset it or pick different values.", + "noProjectsTitle": "No project yet", + "noProjects": "No project has been released for you yet. Ask administration to add you as a moderator.", + "noProjectsForAdmin": "There is no project yet. Create one, then you can write entries for it.", + "toProjects": "Create project", + "columns": { + "number": "Number", + "title": "Title", + "project": "Project", + "type": "Type", + "audience": "Audience", + "status": "Status", + "date": "Date", + "author": "Author" + }, + "pagination": { + "previous": "Back", + "next": "Next", + "status": "Page {page} of {pages}" + }, + "filters": { + "project": "Project", + "allProjects": "All projects", + "status": "Status", + "allStatus": "Any status", + "type": "Type", + "allTypes": "All types", + "search": "Search", + "searchPlaceholder": "Title and teaser", + "submit": "Apply", + "reset": "Reset filter" + }, + "status": { + "draft": "Draft", + "review": "Review", + "scheduled": "Scheduled", + "published": "Published", + "archived": "Withdrawn" + }, + "audience": { + "internal": "Internal", + "customer": "Customer", + "public": "Public" + }, + "fields": { + "title": "Title", + "titlePlaceholder": "What is it about?", + "teaser": "Teaser", + "teaserPlaceholder": "Two sentences that sum up the entry.", + "project": "Project", + "type": "Type", + "audience": "Audience", + "slug": "Slug", + "cover": "Cover image" + }, + "hints": { + "audience": "Customers never see internal entries, public is visible to everyone.", + "teaser": "Shown in the list and in the lead. Leaving it empty is fine.", + "slug": "Part of the address in the archive. Leave it empty to derive it from the title.", + "cover": "Sits large above the entry. Without an image the plate takes that space.", + "drop": "Drag images here or paste them with Ctrl+V, several at once works.", + "schedule": "A moment in the future. The entry appears automatically once it is reached.", + "projectMove": "On a move the entry receives the next free number in the target project." + }, + "save": { + "idle": "Nothing saved yet", + "pending": "Saving", + "dirty": "Unsaved changes", + "saved": "Saved at {time}", + "stored": "Last saved {time}" + }, + "schedule": { + "title": "Schedule" + }, + "actions": { + "save": "Save", + "publish": "Publish", + "publishing": "Publishing", + "schedule": "Set date", + "unschedule": "Drop date", + "archive": "Withdraw", + "resume": "Back to draft", + "preview": "Preview", + "edit": "Edit", + "addBlock": "Add block", + "remove": "Remove block", + "up": "Move up", + "down": "Move down", + "drag": "Move block", + "choose": "Choose image", + "change": "Other image", + "clear": "Remove image", + "upload": "Upload", + "close": "Close" + }, + "blocks": { + "text": "Text", + "image": "Image", + "gallery": "Gallery", + "before_after": "Before and after", + "video": "Video", + "quote": "Quote", + "code": "Code", + "link": "Link", + "callout": "Callout" + }, + "blockFields": { + "text": "Text", + "textPlaceholder": "Write in full sentences. An empty line separates paragraphs.", + "image": "Image", + "gallery": "Images", + "galleryHint": "The order follows your selection.", + "before": "Before", + "after": "After", + "video": "Video file", + "videoHint": "Without a file a link to the video is enough.", + "url": "Address", + "label": "Label", + "description": "Description", + "quote": "Quote", + "source": "Source", + "language": "Language", + "code": "Code", + "tone": "Tone", + "toneInfo": "Note", + "toneWarning": "Warning" + }, + "media": { + "empty": "This project has no image yet. Upload one or drag it into the editor.", + "altMissing": "no alt text" + }, + "uploads": { + "pending": "{name} is uploading", + "done": "{name} is uploaded", + "failed": "{name}: {reason}" + }, + "preview": { + "scope": "View as", + "untitled": "Untitled", + "empty": "This entry has no content yet.", + "hiddenTitle": "Invisible for this audience", + "hidden": "As {scope} this entry is not visible, it is meant for {audience}." + }, + "checks": { + "blockersTitle": "This holds publishing back", + "title_missing": "The title is missing.", + "content_empty": "The entry has no content yet.", + "alt_text_missing": "An image has no alt text. Public entries require one.", + "tags_missing": "No tag set yet.", + "cover_missing": "No cover image set yet." + }, + "errors": { + "forbidden": "Your account lacks the right for this.", + "projectUnknown": "This project does not exist.", + "typeUnknown": "This post type is no longer available. Pick a different one.", + "projectLocked": "The project cannot be changed once the entry exists.", + "notFound": "This entry is gone. Reload the page.", + "titleMissing": "Enter a title first.", + "titleTooLong": "The title is too long.", + "teaserTooLong": "The teaser is too long.", + "slugInvalid": "The slug consists of lowercase letters, digits and hyphens, at least two characters.", + "slugTaken": "This slug already exists in this project.", + "blocksInvalid": "A block is incomplete. Check the addresses and the images.", + "blocked": "Publishing is not possible yet.", + "statusInvalid": "This step is not possible from the current status.", + "scheduleMissing": "Pick a date and time.", + "schedulePast": "The date has to be in the future.", + "uploadFailed": "The upload did not work just now. Try again in a moment.", + "fileMissing": "Choose an image file.", + "fileTooLarge": "The file is larger than 15 MB. Shrink it and try again.", + "unsupportedType": "This format is not accepted. Use JPEG, PNG, WebP, AVIF, GIF or TIFF.", + "unreadableImage": "The file cannot be read as an image. Check whether it is complete.", + "unknown": "That did not work just now. Try again." + } + }, + "brands": { + "eyebrow": "Administration", + "title": "Brands", + "intro": "Brands group projects, by company for example. Every project belongs to exactly one brand.", + "create": "Create brand", + "createTitle": "New brand", + "createIntro": "Name and colour are enough. The slug is suggested from the name.", + "editIntro": "Changes take effect right away for all projects of this brand.", + "save": "Save", + "saving": "Saving", + "back": "Back to the list", + "emptyTitle": "No brand yet", + "empty": "There is no brand yet. Create the first one, then you can create projects.", + "columns": { + "name": "Name", + "slug": "Slug", + "color": "Colour", + "projects": "Projects", + "sort": "Sorting", + "edit": "Edit" + }, + "fields": { + "name": "Name", + "slug": "Slug", + "color": "Colour", + "colorPicker": "Pick a colour", + "sort": "Sorting" + }, + "hints": { + "slug": "Lower case letters, digits and hyphens.", + "color": "As a hex value, for example #2b4a9b.", + "sort": "Smaller numbers come first." + } + }, + "postTypes": { + "eyebrow": "Admin", + "title": "Post types", + "intro": "Post types mark what a post is about. Key, label, colour and order are set here.", + "create": "Add post type", + "createTitle": "New post type", + "createIntro": "Label and colour are enough. The key is derived from the German label.", + "editIntro": "Changes apply at once to every post of this type.", + "save": "Save", + "saving": "Saving", + "back": "Back to the list", + "emptyTitle": "No post type yet", + "empty": "There is no post type yet. Add the first one, then you can classify posts.", + "active": "Active", + "inactive": "Retired", + "usage": "{count, plural, =0 {no post} one {# post} other {# posts}}", + "deleteTitle": "Delete", + "delete": "Delete post type", + "deleting": "Deleting", + "deleteBlocked": "This type is used by {count, plural, one {# post} other {# posts}} and cannot be deleted. Clear the active checkbox instead, then it is no longer offered.", + "columns": { + "label": "Label", + "key": "Key", + "color": "Colour", + "posts": "Posts", + "sort": "Order", + "active": "Active", + "edit": "Edit" + }, + "fields": { + "labelDe": "Label German", + "labelEn": "Label English", + "key": "Key", + "color": "Colour", + "colorPicker": "Pick a colour", + "sort": "Order", + "isActive": "Active" + }, + "hints": { + "key": "Lower case letters, digits and hyphens. This is what the API reports.", + "color": "As a hex value, for example #2b4a9b. It marks the type on a post.", + "sort": "Smaller numbers come first, in the picker and in the filters.", + "isActive": "Only active types are offered while writing.", + "activeInUse": "Only active types are offered while writing. Posts that already carry this type keep it." + } + }, + "users": { + "eyebrow": "Administration", + "title": "Users", + "intro": "Only people who write need an account. Reading works without one.", + "columns": { + "name": "Name", + "email": "Email", + "role": "Right", + "projects": "Projects", + "edit": "Open" + }, + "roles": { + "admin": "Administration", + "moderator": "Moderator" + }, + "you": "you", + "all": "all", + "back": "Back to the list", + "saving": "Saving", + "emptyTitle": "No account yet", + "empty": "There is no account yet. Create one with the command pnpm admin:create.", + "newAccountTitle": "New account", + "newAccount": "Accounts are created with the command pnpm admin:create. After that you hand out the rights here.", + "adminSection": "Administration right", + "isAdmin": "This account administers everything and reaches every project.", + "isNotAdmin": "This account works only in the projects it has been released for.", + "grantAdmin": "Grant administration right", + "revokeAdmin": "Revoke administration right", + "selfHint": "The administration right cannot be changed on your own account.", + "rolesSection": "Projects", + "assignedMeta": "{count, plural, =0 {no project} one {# project} other {# projects}}", + "adminHasAll": "This account holds the administration right and reaches every project anyway. The releases below apply once the administration right is revoked.", + "grantRole": "Release", + "revokeRole": "Withdraw", + "implicit": "through administration right", + "rolesHint": "A moderator creates and publishes entries and images of their projects.", + "noProjectsTitle": "No project yet", + "noProjects": "There is no project you could release yet.", + "toProjects": "Create project", + "accountSection": "Account", + "accountHint": "Name, email and password are set by the command pnpm admin:create." + }, + "clients": { + "eyebrow": "Administration", + "title": "API access", + "intro": "Applications fetch entries through an access. Read access delivers published entries of its audience, write access creates drafts.", + "listTitle": "Access", + "meta": "{count, plural, =0 {no access} one {# access} other {# accesses}}", + "emptyTitle": "No access yet", + "empty": "There is no access yet. Create one below so an application can fetch entries.", + "columns": { + "name": "Name", + "mode": "Mode", + "scope": "Audience", + "project": "Project", + "lastUsed": "Last used", + "state": "State", + "action": "Action" + }, + "modes": { + "read": "Read", + "write": "Write" + }, + "scopes": { + "internal": "Internal", + "customer": "Customer", + "public": "Public" + }, + "allProjects": "All projects", + "unknownProject": "Unknown", + "never": "never", + "activeState": "Active", + "revoked": "Revoked", + "revoke": "Revoke", + "revoking": "Revoking", + "createTitle": "Create access", + "createIntro": "The token is shown once and stored as a hash only. Whoever loses it creates a new access and revokes the old one.", + "create": "Create access", + "creating": "Creating", + "tokenTitle": "Token, visible now only", + "tokenHint": "Copy the token now and put it into the application. It cannot be shown again later.", + "copy": "Copy token", + "copied": "Copied", + "fields": { + "name": "Name", + "mode": "Mode", + "scope": "Audience", + "project": "Project" + }, + "hints": { + "name": "How you recognise this access later, for example Trakk Web.", + "mode": "Read fetches entries. Write creates drafts and uploads images.", + "scope": "Decides which entries the access sees. Customer never gets internal entries.", + "project": "Without a binding the access covers all projects." + }, + "publishTitle": "Publishing", + "publishHint": "No access may publish. That stays bound to accounts." + } + }, + "filter": { + "type": "Type", + "allTypes": "All types" + }, + "media": { + "eyebrow": "Images", + "title": "Media library", + "intro": "One image stock per project. Uploading creates WebP renditions in several widths automatically.", + "back": "All projects", + "library": "Stock", + "projectIntro": "Upload images and maintain alt text and caption. Without alt text a public entry cannot be published.", + "projectMeta": "{count, plural, =0 {no image} one {# image} other {# images}}", + "emptyProjectsTitle": "No project yet", + "emptyProjects": "No project has been released for you yet, or none has been created.", + "emptyLibraryTitle": "No image yet", + "emptyLibrary": "This project holds no image yet. Upload the first one with the field above.", + "upload": { + "title": "Upload image", + "hint": "JPEG, PNG, WebP, AVIF, GIF or TIFF. The original is kept, the renditions are made from it.", + "file": "File", + "submit": "Upload", + "pending": "Processing", + "limit": "15 MB at most" + }, + "fields": { + "alt": "Alt text", + "altHint": "Mandatory for public entries, a recommendation otherwise.", + "altPlaceholder": "What can be seen in the image?", + "caption": "Caption" + }, + "meta": { + "submit": "Save", + "pending": "Saving" + }, + "card": { + "meta": "{width} × {height} · {size} KB · {variants, plural, =0 {no rendition} one {# rendition} other {# renditions}}", + "altMissing": "No alt text. It is mandatory for public entries.", + "variantError": "The renditions could not be created. The original is stored and usable." + }, + "messages": { + "uploaded": "The image is uploaded and the renditions are created.", + "saved": "Alt text and caption are saved.", + "fileMissing": "Pick an image file.", + "fileTooLarge": "The file is larger than 15 MB. Shrink it and try again.", + "unsupportedType": "This format is not accepted. Use JPEG, PNG, WebP, AVIF, GIF or TIFF.", + "unreadableImage": "The file cannot be read as an image. Check whether it is complete.", + "projectUnknown": "This project does not exist.", + "mediaUnknown": "This image no longer exists.", + "uploadFailed": "Uploading did not work just now. Try again in a moment." + } + }, + "mail": { + "magicLink": { + "subject": "Your sign-in link for Logbuch", + "greeting": "Hello,", + "body": "here is your sign-in link for Logbuch. It is valid for {minutes} minutes and can be used once.", + "ignore": "If you did not request a link, ignore this message. Nothing happens without the link.", + "signature": "Logbuch" + } + } +} diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 0000000..c47a734 --- /dev/null +++ b/next.config.ts @@ -0,0 +1,22 @@ +import type { NextConfig } from 'next' +import createNextIntlPlugin from 'next-intl/plugin' + +const config: NextConfig = { + typedRoutes: true, + experimental: { + useTypeScriptCli: true, + serverActions: { + bodySizeLimit: '16mb', + }, + }, + images: { + formats: ['image/webp'], + deviceSizes: [480, 640, 960, 1200, 1600, 1920], + imageSizes: [96, 160, 240, 320], + minimumCacheTTL: 31536000, + }, +} + +const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts') + +export default withNextIntl(config) diff --git a/package.json b/package.json new file mode 100644 index 0000000..cb7f9e8 --- /dev/null +++ b/package.json @@ -0,0 +1,54 @@ +{ + "name": "logbuch", + "version": "0.1.0", + "private": true, + "type": "module", + "packageManager": "pnpm@10.30.0", + "scripts": { + "dev": "next dev --port 4700", + "build": "next build", + "start": "next start --port 4700", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate", + "db:seed": "tsx scripts/seed.ts", + "admin:create": "tsx scripts/create-admin.ts", + "publish:due": "tsx scripts/publish-due.ts" + }, + "dependencies": { + "better-auth": "1.6.25", + "drizzle-orm": "0.45.2", + "next": "16.2.12", + "next-intl": "4.13.4", + "nodemailer": "^9.0.3", + "overlayscrollbars": "2.16.0", + "overlayscrollbars-react": "0.5.6", + "pg": "8.22.0", + "react": "19.2.8", + "react-dom": "19.2.8", + "react-icons": "5.7.0", + "sharp": "^0.35.3", + "zod": "4.4.3" + }, + "devDependencies": { + "@tailwindcss/postcss": "4.3.3", + "@types/node": "^26.1.2", + "@types/nodemailer": "^8.0.1", + "@types/pg": "^8.20.0", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "dotenv": "^17.4.2", + "drizzle-kit": "0.31.10", + "tailwindcss": "4.3.3", + "tsx": "^4.23.1", + "typescript": "^7.0.2", + "vitest": "4.1.10" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "esbuild" + ] + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..b9047d0 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,4033 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + better-auth: + specifier: 1.6.25 + version: 1.6.25(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0))(next@16.2.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.10(@types/node@26.1.2)(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1))) + drizzle-orm: + specifier: 0.45.2 + version: 0.45.2(@types/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0) + next: + specifier: 16.2.12 + version: 16.2.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + next-intl: + specifier: 4.13.4 + version: 4.13.4(next@16.2.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(typescript@7.0.2) + nodemailer: + specifier: ^9.0.3 + version: 9.0.3 + overlayscrollbars: + specifier: 2.16.0 + version: 2.16.0 + overlayscrollbars-react: + specifier: 0.5.6 + version: 0.5.6(overlayscrollbars@2.16.0)(react@19.2.8) + pg: + specifier: 8.22.0 + version: 8.22.0 + react: + specifier: 19.2.8 + version: 19.2.8 + react-dom: + specifier: 19.2.8 + version: 19.2.8(react@19.2.8) + react-icons: + specifier: 5.7.0 + version: 5.7.0(react@19.2.8) + sharp: + specifier: ^0.35.3 + version: 0.35.3(@types/node@26.1.2) + zod: + specifier: 4.4.3 + version: 4.4.3 + devDependencies: + '@tailwindcss/postcss': + specifier: 4.3.3 + version: 4.3.3 + '@types/node': + specifier: ^26.1.2 + version: 26.1.2 + '@types/nodemailer': + specifier: ^8.0.1 + version: 8.0.1 + '@types/pg': + specifier: ^8.20.0 + version: 8.20.0 + '@types/react': + specifier: ^19.2.17 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.17) + dotenv: + specifier: ^17.4.2 + version: 17.4.2 + drizzle-kit: + specifier: 0.31.10 + version: 0.31.10 + tailwindcss: + specifier: 4.3.3 + version: 4.3.3 + tsx: + specifier: ^4.23.1 + version: 4.23.1 + typescript: + specifier: ^7.0.2 + version: 7.0.2 + vitest: + specifier: 4.1.10 + version: 4.1.10(@types/node@26.1.2)(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) + +packages: + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@better-auth/core@1.6.25': + resolution: {integrity: sha512-lMTlhtwyK4NpY9kPF+2rQCRKYpg136d3gM2xl8esxT1PjJx5Nh5YwZvxcYCIjDuO759sx6TCloJTuwcZGG6ZBw==} + peerDependencies: + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + '@cloudflare/workers-types': '>=4' + '@opentelemetry/api': ^1.9.0 + better-call: 1.3.7 + jose: ^6.1.0 + kysely: ^0.28.5 || ^0.29.0 + nanostores: ^1.0.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + '@opentelemetry/api': + optional: true + + '@better-auth/drizzle-adapter@1.6.25': + resolution: {integrity: sha512-ru/DeKjFPQUVeKkxF/ScazmPqIY7lwfkAV5Yt4j24wmn1Y8vFwoiPRnHgXUeZqBs10+nubaRwEqLF39CP6EhRw==} + peerDependencies: + '@better-auth/core': ^1.6.25 + '@better-auth/utils': 0.4.2 + drizzle-orm: ^0.45.2 + peerDependenciesMeta: + drizzle-orm: + optional: true + + '@better-auth/kysely-adapter@1.6.25': + resolution: {integrity: sha512-zxiePhtN1YClS1irKYPVwWfN6kYp+QoYlz1hdQUOj8hXyo2aE/ny4RNAb6v332b0+U6Vu88EhYITRPdmvCo6uA==} + peerDependencies: + '@better-auth/core': ^1.6.25 + '@better-auth/utils': 0.4.2 + kysely: ^0.28.17 || ^0.29.0 + peerDependenciesMeta: + kysely: + optional: true + + '@better-auth/memory-adapter@1.6.25': + resolution: {integrity: sha512-GhEzTumc8yfTz+OZ6pMg06BA49xob49x1bX+1mEl/FStDJoSF+6mTfI5M2ytFxaiN89336/aUjkW8u+qRyLexw==} + peerDependencies: + '@better-auth/core': ^1.6.25 + '@better-auth/utils': 0.4.2 + + '@better-auth/mongo-adapter@1.6.25': + resolution: {integrity: sha512-ZtMmjcOdXR2Ziqx5y8ptTOaNpe0snNfALbBUPXJsgeyeRkDJDYzyLZ8MpuvNBTNllNeIFDbiXWAK5k+pEBZrUQ==} + peerDependencies: + '@better-auth/core': ^1.6.25 + '@better-auth/utils': 0.4.2 + mongodb: ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + mongodb: + optional: true + + '@better-auth/prisma-adapter@1.6.25': + resolution: {integrity: sha512-ym7B6Iqcry+/4aQnYpFwqP/GBIiXvjrm/5B6+0qmx8mkTY/apHFTpHuGzUYYNf4vPTtzF3eYY2+s2GOsomKaRg==} + peerDependencies: + '@better-auth/core': ^1.6.25 + '@better-auth/utils': 0.4.2 + '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 + prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + '@prisma/client': + optional: true + prisma: + optional: true + + '@better-auth/telemetry@1.6.25': + resolution: {integrity: sha512-2ZfC9lp7tU6Jw/q2Lz/bKfQqGMdMwc/IQDTYdBhvtGi24qInYVnhp2ZCW57hHM9j+fq1ULOtxgg6M3T1LEaihw==} + peerDependencies: + '@better-auth/core': ^1.6.25 + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + + '@better-auth/utils@0.4.2': + resolution: {integrity: sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==} + + '@better-fetch/fetch@1.3.1': + resolution: {integrity: sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g==} + + '@drizzle-team/brocli@0.10.2': + resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} + + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@esbuild-kit/core-utils@3.3.2': + resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} + deprecated: 'Merged into tsx: https://tsx.hirok.io' + + '@esbuild-kit/esm-loader@2.6.5': + resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} + deprecated: 'Merged into tsx: https://tsx.hirok.io' + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.18.20': + resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.18.20': + resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.18.20': + resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.18.20': + resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.18.20': + resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.18.20': + resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.18.20': + resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.18.20': + resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.18.20': + resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.18.20': + resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.18.20': + resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.18.20': + resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.18.20': + resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.18.20': + resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.18.20': + resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.18.20': + resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.18.20': + resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.18.20': + resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.18.20': + resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.18.20': + resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.18.20': + resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.18.20': + resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@formatjs/fast-memoize@3.1.7': + resolution: {integrity: sha512-zXfhLpvA6T7+efdt9JLbBwZ00tT7NsBMDVnDu8rpHeNNv8KfRZAMo2gkG0k9lK/Nzc//3kJ9pImsfuJxk3KhUA==} + + '@formatjs/icu-messageformat-parser@3.5.15': + resolution: {integrity: sha512-5o4grXKotAB3JqQuisLApHG43g17N+paoRTa92Jiz35Zvfemq0cVf4EDvuxyHAzmsJji7igaEowicLO/VmfJ8Q==} + + '@formatjs/icu-skeleton-parser@2.1.11': + resolution: {integrity: sha512-j8cUmOJzVgkHuS0QiQ6ga76UIoLOFSAMWhs7aZJztH3aAdCOAE6vpC8KVvFB4cU10ON0y2/5oOVmPJ43s2lTwA==} + + '@formatjs/intl-localematcher@0.8.13': + resolution: {integrity: sha512-kHEAFOkeJSPNi7c5PaKaRjxcBrJwzzt81ifUu+8uve1EDW/VJl83KsxmqgqNZLzcFEhSliZGvx3+pk/RH0IOmg==} + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-arm64@0.35.3': + resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-darwin-x64@0.35.3': + resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.3': + resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.3.2': + resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.2': + resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm64@1.3.2': + resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.3.2': + resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.3.2': + resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.3.2': + resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.3.2': + resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.3.2': + resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.3.2': + resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm64@0.35.3': + resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.35.3': + resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.35.3': + resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.35.3': + resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.35.3': + resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.35.3': + resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-arm64@0.35.3': + resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.35.3': + resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-wasm32@0.35.3': + resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.3': + resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-arm64@0.35.3': + resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-ia32@0.35.3': + resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@img/sharp-win32-x64@0.35.3': + resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/wasm-runtime@1.2.1': + resolution: {integrity: sha512-KjZdi8Q1wh89gsVmghvbrMgWl6ZWmRmHV6wjB7/g4Zf0dyO+hH3neZUtuDNPO00qq5YE5RITVWvrIZKRaAmzGQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.3 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.3 + + '@next/env@16.2.12': + resolution: {integrity: sha512-d0Z5Bc13Fa4nR8pFAKx2jay2yhJM16vlfHbTzYnUQAxlNb6B6lmn4hjt69lYNt4kRtyYP6gEM49lPRHNbIyneg==} + + '@next/swc-darwin-arm64@16.2.12': + resolution: {integrity: sha512-0W1R0teHWJrqKX0FH20IzzIWAOuGtBxPGuObrxy1lE8hQvCFj49KE8a3WUg0D7sq6rn6zkM4c7YGUnhudBS6oA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@16.2.12': + resolution: {integrity: sha512-Hy5Ls099+aFUmOLmIgPfLqNi6iCwhL3uQCssz5rWk+5Nkc6TUKCE83DY5BbNylfm3+mfwcSFnLRfrZDJhVxdtw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@16.2.12': + resolution: {integrity: sha512-+YqU2h1cQkHsGfvjAsrSmst8UIFBibBGm5x3Xgel8NLMiDQtNOM4sM2GOEMvG5YiOBNeN/Ykk8cQC2S0Xrqljg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@next/swc-linux-arm64-musl@16.2.12': + resolution: {integrity: sha512-0qjhiYBaKAqF63LA1ZWAAnKTzFUguAaZiRa5etMLGGPj/B6uEVjtIZldIzFEp3wHlB0koK6aTzqPtSdplTCjoA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@next/swc-linux-x64-gnu@16.2.12': + resolution: {integrity: sha512-7A3q26W+h7gnA15uqBToNuDqBEFZZcqh0mW2mn4AJh/G5pdg2RVE3n4slzLEliASZFG3NmsbEzng/x2Sh09mBg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@next/swc-linux-x64-musl@16.2.12': + resolution: {integrity: sha512-qSjL/uppm+cbh21s72Ss8gkiOhQ4dExWHNGOWy6eZV7STj5WsKehgxT61beSsOj+YYQuTplL376lOCdMQU5T8w==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@next/swc-win32-arm64-msvc@16.2.12': + resolution: {integrity: sha512-X6hzsOUJac/e7AWSbn9gQ9nzHld1xWP5iyjHpYWvud8pufB679O1xg4JDyKr8Xd69Jvd+kM2Der6uftiZCmjYA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-x64-msvc@16.2.12': + resolution: {integrity: sha512-F6fakeHuFTLOPt0bslQJdf+xtT+WIP9DVn/m4y1w1mRnVPyh3D/cNvzlRkxM444xfm+IvvYNSOrKiA2CDJ0Uxw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@noble/ciphers@2.2.0': + resolution: {integrity: sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==} + engines: {node: '>= 20.19.0'} + + '@noble/hashes@2.2.0': + resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} + engines: {node: '>= 20.19.0'} + + '@opentelemetry/semantic-conventions@1.43.0': + resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} + engines: {node: '>=14'} + + '@oxc-project/types@0.139.0': + resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} + + '@parcel/watcher-android-arm64@2.6.0': + resolution: {integrity: sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [android] + + '@parcel/watcher-darwin-arm64@2.6.0': + resolution: {integrity: sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [darwin] + + '@parcel/watcher-darwin-x64@2.6.0': + resolution: {integrity: sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [darwin] + + '@parcel/watcher-freebsd-x64@2.6.0': + resolution: {integrity: sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + + '@parcel/watcher-linux-arm-glibc@2.6.0': + resolution: {integrity: sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm-musl@2.6.0': + resolution: {integrity: sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-arm64-glibc@2.6.0': + resolution: {integrity: sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm64-musl@2.6.0': + resolution: {integrity: sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-x64-glibc@2.6.0': + resolution: {integrity: sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-x64-musl@2.6.0': + resolution: {integrity: sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@parcel/watcher-win32-arm64@2.6.0': + resolution: {integrity: sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [win32] + + '@parcel/watcher-win32-x64@2.6.0': + resolution: {integrity: sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [win32] + + '@parcel/watcher@2.6.0': + resolution: {integrity: sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==} + engines: {node: '>= 10.0.0'} + + '@rolldown/binding-android-arm64@1.1.5': + resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.1.5': + resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.1.5': + resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.1.5': + resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.1.5': + resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.1.5': + resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.1.5': + resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.1.5': + resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.1.5': + resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.1.5': + resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.1.5': + resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.1.5': + resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.1.5': + resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@schummar/icu-type-parser@1.21.5': + resolution: {integrity: sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@swc/core-darwin-arm64@1.15.47': + resolution: {integrity: sha512-GsoMtan3ojGGMGFbl31mmRu5ctZ56re8grGE8mO/OHJ8O+JRkzod02fe7X6ZQ8JvamA3imkEkx/h3u+vsOgPgA==} + engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] + + '@swc/core-darwin-x64@1.15.47': + resolution: {integrity: sha512-leTi7Rx3KF4zcC637iqWgk9SoV8VXAD8ppQYXsep63px5A/UftOcxLN1pmr8Z1si/YvX90ompP/rHgpYkgwXWg==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] + + '@swc/core-linux-arm-gnueabihf@1.15.47': + resolution: {integrity: sha512-hBqHuoWKKIsKmDBn9qVeWqj5GWZhtlcczVaqQmNRXsDfq+voR5CxKRfamA367QjJXtceYuliLFfEL8QsskRM2g==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] + + '@swc/core-linux-arm64-gnu@1.15.47': + resolution: {integrity: sha512-TBxvRz+B4K205TWHHZxWVxkC2RFNP/Mz3PNcECBos5PsKwxjg3QSJzdoebr0VCf0Bfh8HOPldKxAP/8XkFe9gA==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-arm64-musl@1.15.47': + resolution: {integrity: sha512-3Yu3Uq/VgytqsPjTMbkPU1ExADytbdWbruJYhA584E9jrpE2Ki+R6VVPoZCeAVk1Cb7QxcRTgblw6bSa6a/R+w==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@swc/core-linux-ppc64-gnu@1.15.47': + resolution: {integrity: sha512-wfdMi5IaOaNtmh2/6geRoxIdNfqylUZFdtzTKS655y1axWfIWyx7As74vv0wVdjeCIZ3WmCI9odDd4rUttXOSQ==} + engines: {node: '>=10'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-s390x-gnu@1.15.47': + resolution: {integrity: sha512-3hHYBY0yx8Ez7GMRrkhXHQzMdR5IZA6Wq5Ee4svlgwvSECLpnAJ9+0AimEGUFDvuLwE7nV/2+PYe8+Nm4rvNcQ==} + engines: {node: '>=10'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@swc/core-linux-x64-gnu@1.15.47': + resolution: {integrity: sha512-TjfhjgP/jGCfFHYC3JQPhJA1HwErbIJ9JfREDc1KNkvY6P0LodCgKVIlQ5deeTbkG7ih3bF5PHJLuLpaZjdRyQ==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-x64-musl@1.15.47': + resolution: {integrity: sha512-CQpS8Ge/avfjZd0UEwG/sds83Uu32deQXcV1Jo3jD0mmvQQqtYAjpsDZXugmheeAwmt+YIuoVtVHro8LMYHqsQ==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@swc/core-win32-arm64-msvc@1.15.47': + resolution: {integrity: sha512-0W8IKHsUTYiT7G2RqtOoVWk+89yzZikIiDUb/sCK6BmQDBhN91hQSfyUtW12jhEWLzYgcfmisfsZrmZE+84U1A==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@swc/core-win32-ia32-msvc@1.15.47': + resolution: {integrity: sha512-ZIp49d2Z4/ka2jO9otOg4hDvTdPmp86kVOgS2M5FCPI7eKKZ1W0boxWn+8XeZrfERtFGW0AlMRm4JhlJa7l3NA==} + engines: {node: '>=10'} + cpu: [ia32] + os: [win32] + + '@swc/core-win32-x64-msvc@1.15.47': + resolution: {integrity: sha512-2h8Iek95vnixkBRCo+H8p09+Q5ll2NgSMFrWTy0iKt7+/t+8/T5mBpiT6c0ZxSS7wcWjwZ9sGZkK70tTSYHdDw==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@swc/core@1.15.47': + resolution: {integrity: sha512-FbsO5JcfOjfH38W/rohBRBweJeERsAuIP4f377lmkmxTcq9exjtx4SkRuZY5CdfhR2CBVwDIJegBpJDffwNsOg==} + engines: {node: '>=10'} + peerDependencies: + '@swc/helpers': '>=0.5.17' + peerDependenciesMeta: + '@swc/helpers': + optional: true + + '@swc/counter@0.1.3': + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + + '@swc/helpers@0.5.15': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + + '@swc/types@0.1.27': + resolution: {integrity: sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg==} + + '@tailwindcss/node@4.3.3': + resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} + + '@tailwindcss/oxide-android-arm64@4.3.3': + resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.3': + resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.3': + resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==} + engines: {node: '>= 20'} + + '@tailwindcss/postcss@4.3.3': + resolution: {integrity: sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==} + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@26.1.2': + resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==} + + '@types/nodemailer@8.0.1': + resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==} + + '@types/pg@8.20.0': + resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + baseline-browser-mapping@2.11.7: + resolution: {integrity: sha512-APw5YuIQAg6L9w4sHDI6j26DGFJI6RpYOhnkMPdC9lWbkKvsyPHzDsve1yd73lk21yz7Y09Kci8B2Pp9FonzWA==} + engines: {node: '>=6.0.0'} + hasBin: true + + better-auth@1.6.25: + resolution: {integrity: sha512-fvoq+oCO+FF5fpP3XfU7znRyGFpHB77UG2EyxsKNy+Cak7Q5pELu+auvvDveQbWQxcoKugZ7jYQQPFQLpUTGOw==} + peerDependencies: + '@lynx-js/react': '*' + '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 + '@sveltejs/kit': ^2.0.0 + '@tanstack/react-start': ^1.0.0 + '@tanstack/solid-start': ^1.0.0 + better-sqlite3: ^12.0.0 + drizzle-kit: '>=0.31.4' + drizzle-orm: ^0.45.2 + mongodb: ^6.0.0 || ^7.0.0 + mysql2: ^3.0.0 + next: ^14.0.0 || ^15.0.0 || ^16.0.0 + pg: ^8.0.0 + prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + solid-js: ^1.0.0 + svelte: ^4.0.0 || ^5.0.0 + vitest: ^2.0.0 || ^3.0.0 || ^4.0.0 + vue: ^3.0.0 + peerDependenciesMeta: + '@lynx-js/react': + optional: true + '@prisma/client': + optional: true + '@sveltejs/kit': + optional: true + '@tanstack/react-start': + optional: true + '@tanstack/solid-start': + optional: true + better-sqlite3: + optional: true + drizzle-kit: + optional: true + drizzle-orm: + optional: true + mongodb: + optional: true + mysql2: + optional: true + next: + optional: true + pg: + optional: true + prisma: + optional: true + react: + optional: true + react-dom: + optional: true + solid-js: + optional: true + svelte: + optional: true + vitest: + optional: true + vue: + optional: true + + better-call@1.3.7: + resolution: {integrity: sha512-Al51/hjp2SSp6CRTa3F2ptcx4yQVS1xWKoY6jcVXqNYOap6mHFP2jUBn5EwIL4iIed1/Sq4hlQ+Umm6EflZG+w==} + peerDependencies: + zod: ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + caniuse-lite@1.0.30001806: + resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + + drizzle-kit@0.31.10: + resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==} + hasBin: true + + drizzle-orm@0.45.2: + resolution: {integrity: sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=4' + '@electric-sql/pglite': '>=0.2.0' + '@libsql/client': '>=0.10.0' + '@libsql/client-wasm': '>=0.10.0' + '@neondatabase/serverless': '>=0.10.0' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1.13' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/sql.js': '*' + '@upstash/redis': '>=1.34.7' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=14.0.0' + gel: '>=2' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@libsql/client-wasm': + optional: true + '@neondatabase/serverless': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/sql.js': + optional: true + '@upstash/redis': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + gel: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + + enhanced-resolve@5.24.4: + resolution: {integrity: sha512-GVoi+ICHocoOIU7qVVM48wOJziRsqrsyqlI0Ce0LdowRn6v3bcH2zUa9kp85ncx0nwIb9/HOCOLS3fdThDG/XQ==} + engines: {node: '>=10.13.0'} + + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + + esbuild@0.18.20: + resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + icu-minify@4.13.4: + resolution: {integrity: sha512-yK6HyPLGlQjqm8fTKtnBpM77z7vl7JdDBN2EXLvmgAu/b7XaOHWZb73M3ISl9ahBTehBv7RYeqqWSHfk1v2YcA==} + + intl-messageformat@11.2.12: + resolution: {integrity: sha512-KW70Xxfcvy7vV3qODfvShWkFDPMqKDAa4N+hSyVBWGNtVhTUFYaqlD/l88DaYPKiVcPP4rPQ3qnH7i5K82Mg7g==} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + jose@6.2.5: + resolution: {integrity: sha512-2E5L2yRp03FnwreJLJX8/r7mHiZICCf8kG7fAsTWkSQTDAcc46NIZoQLKy+EJ8sPoJlxyS4OQR5H70LjIZZlIQ==} + + kysely@0.29.4: + resolution: {integrity: sha512-y5mVgQNkMbs1eK9Xyc0pmNdabN2wHhRYY/5r4W5HrUT1rYCEPeVNSj1RUJeSDKT3U0p+mXCvLgkrFuIafYI6BA==} + engines: {node: '>=22.0.0'} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + nanostores@1.4.2: + resolution: {integrity: sha512-Wxv8Roefr2nqtiRG0bnaFlpYqpIVtOEeJZHaH+4nGgOK1/7n6OHOuHCb/bhqrNQgZM8fyd0s1PqhdrJc9Ib44g==} + engines: {node: ^20.0.0 || >=22.0.0} + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + next-intl-swc-plugin-extractor@4.13.4: + resolution: {integrity: sha512-uN1+NMUYbG6YkO3q+rjc2bvAPX9nQ23owemvHJAyW0pRbQjVDwvNhmrV5qaak0oQc/9okbK17KLT49AoMGhVEQ==} + + next-intl@4.13.4: + resolution: {integrity: sha512-jhPAT0u0lahIK6E4gVdZAehugWCosBhLG8sV7xMzgSVoJpxHObP+Fiu+z2FfkEW0XPPtr7uEXoUlLEfhxhNMTg==} + peerDependencies: + next: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + next@16.2.12: + resolution: {integrity: sha512-iD59eYQWmbFcEbX7v/acG5DRym9iw1DdaPoD0WTA920naWsE25wShzJW4+UvAs8MK9EC2kBfIH6vtto1H1PHGw==} + engines: {node: '>=20.9.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + + nodemailer@9.0.3: + resolution: {integrity: sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==} + engines: {node: '>=6.0.0'} + + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + + overlayscrollbars-react@0.5.6: + resolution: {integrity: sha512-E5To04bL5brn9GVCZ36SnfGanxa2I2MDkWoa4Cjo5wol7l+diAgi4DBc983V7l2nOk/OLJ6Feg4kySspQEGDBw==} + peerDependencies: + overlayscrollbars: ^2.0.0 + react: '>=16.8.0' + + overlayscrollbars@2.16.0: + resolution: {integrity: sha512-N03oje/q7j93D0aLZtoCdsDSYLmhheSsv8H7oSLE7HhdV9P/bmCURtLV/KbPye7P/bpfyt/obSfDpGUYoJ0OWg==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pg-cloudflare@1.4.0: + resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} + + pg-connection-string@2.14.0: + resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.14.0: + resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.15.0: + resolution: {integrity: sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.22.0: + resolution: {integrity: sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + po-parser@2.1.1: + resolution: {integrity: sha512-ECF4zHLbUItpUgE3OTtLKlPjeBN+fKEczj2zYjDfCGOzicNs0GK3Vg2IoAYwx7LH/XYw43fZQP6xnZ4TkNxSLQ==} + + postcss@8.4.31: + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + engines: {node: ^10 || ^12 || >=14} + + postcss@8.5.25: + resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} + engines: {node: ^10 || ^12 || >=14} + + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + peerDependencies: + react: ^19.2.8 + + react-icons@5.7.0: + resolution: {integrity: sha512-LBLy340Rzqy6+/yVhZKT3B/QpP1BZaesGqasf09HPOBzRarcDIFH0WwXlXQfE7q7ipxK4MSiC5DIBWURCny6fw==} + peerDependencies: + react: '*' + + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + engines: {node: '>=0.10.0'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + rolldown@1.1.5: + resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + rou3@0.7.12: + resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + set-cookie-parser@3.1.2: + resolution: {integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==} + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + sharp@0.35.3: + resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + + tailwindcss@4.3.3: + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.23.1: + resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + use-intl@4.13.4: + resolution: {integrity: sha512-wRhU5zyPNgu845++EJ8ckQsi89b22QUop7NlGxNXpsnKSwEJr7WErAkdAYeVQgFTmDWsa8e2NI1e14XbWz9Ecw==} + peerDependencies: + react: ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0 + + vite@8.1.5: + resolution: {integrity: sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.3.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@alloc/quick-lru@5.2.0': {} + + '@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2)': + dependencies: + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + '@opentelemetry/semantic-conventions': 1.43.0 + '@standard-schema/spec': 1.1.0 + better-call: 1.3.7(zod@4.4.3) + jose: 6.2.5 + kysely: 0.29.4 + nanostores: 1.4.2 + zod: 4.4.3 + + '@better-auth/drizzle-adapter@1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0))': + dependencies: + '@better-auth/core': 1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2) + '@better-auth/utils': 0.4.2 + optionalDependencies: + drizzle-orm: 0.45.2(@types/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0) + + '@better-auth/kysely-adapter@1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)(kysely@0.29.4)': + dependencies: + '@better-auth/core': 1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2) + '@better-auth/utils': 0.4.2 + optionalDependencies: + kysely: 0.29.4 + + '@better-auth/memory-adapter@1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)': + dependencies: + '@better-auth/core': 1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2) + '@better-auth/utils': 0.4.2 + + '@better-auth/mongo-adapter@1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)': + dependencies: + '@better-auth/core': 1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2) + '@better-auth/utils': 0.4.2 + + '@better-auth/prisma-adapter@1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)': + dependencies: + '@better-auth/core': 1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2) + '@better-auth/utils': 0.4.2 + + '@better-auth/telemetry@1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': + dependencies: + '@better-auth/core': 1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2) + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + + '@better-auth/utils@0.4.2': + dependencies: + '@noble/hashes': 2.2.0 + + '@better-fetch/fetch@1.3.1': {} + + '@drizzle-team/brocli@0.10.2': {} + + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild-kit/core-utils@3.3.2': + dependencies: + esbuild: 0.18.20 + source-map-support: 0.5.21 + + '@esbuild-kit/esm-loader@2.6.5': + dependencies: + '@esbuild-kit/core-utils': 3.3.2 + get-tsconfig: 4.14.0 + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.18.20': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.18.20': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.18.20': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.18.20': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.18.20': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.18.20': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.18.20': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.18.20': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.18.20': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.18.20': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.18.20': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.18.20': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.18.20': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.18.20': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.18.20': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.18.20': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.18.20': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.18.20': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.18.20': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.18.20': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.18.20': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.18.20': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@formatjs/fast-memoize@3.1.7': {} + + '@formatjs/icu-messageformat-parser@3.5.15': + dependencies: + '@formatjs/icu-skeleton-parser': 2.1.11 + + '@formatjs/icu-skeleton-parser@2.1.11': {} + + '@formatjs/intl-localematcher@0.8.13': + dependencies: + '@formatjs/fast-memoize': 3.1.7 + + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.2 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.2 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.3': + dependencies: + '@img/sharp-wasm32': 0.35.3 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.2': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.2': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.2': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.2': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.2': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.2 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.2 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.2 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.2 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.2 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.2 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-wasm32@0.35.3': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.3': + dependencies: + '@img/sharp-wasm32': 0.35.3 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-arm64@0.35.3': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.35.3': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.35.3': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/wasm-runtime@1.2.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@next/env@16.2.12': {} + + '@next/swc-darwin-arm64@16.2.12': + optional: true + + '@next/swc-darwin-x64@16.2.12': + optional: true + + '@next/swc-linux-arm64-gnu@16.2.12': + optional: true + + '@next/swc-linux-arm64-musl@16.2.12': + optional: true + + '@next/swc-linux-x64-gnu@16.2.12': + optional: true + + '@next/swc-linux-x64-musl@16.2.12': + optional: true + + '@next/swc-win32-arm64-msvc@16.2.12': + optional: true + + '@next/swc-win32-x64-msvc@16.2.12': + optional: true + + '@noble/ciphers@2.2.0': {} + + '@noble/hashes@2.2.0': {} + + '@opentelemetry/semantic-conventions@1.43.0': {} + + '@oxc-project/types@0.139.0': {} + + '@parcel/watcher-android-arm64@2.6.0': + optional: true + + '@parcel/watcher-darwin-arm64@2.6.0': + optional: true + + '@parcel/watcher-darwin-x64@2.6.0': + optional: true + + '@parcel/watcher-freebsd-x64@2.6.0': + optional: true + + '@parcel/watcher-linux-arm-glibc@2.6.0': + optional: true + + '@parcel/watcher-linux-arm-musl@2.6.0': + optional: true + + '@parcel/watcher-linux-arm64-glibc@2.6.0': + optional: true + + '@parcel/watcher-linux-arm64-musl@2.6.0': + optional: true + + '@parcel/watcher-linux-x64-glibc@2.6.0': + optional: true + + '@parcel/watcher-linux-x64-musl@2.6.0': + optional: true + + '@parcel/watcher-win32-arm64@2.6.0': + optional: true + + '@parcel/watcher-win32-x64@2.6.0': + optional: true + + '@parcel/watcher@2.6.0': + dependencies: + detect-libc: 2.1.2 + is-glob: 4.0.3 + node-addon-api: 7.1.1 + picomatch: 4.0.5 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.6.0 + '@parcel/watcher-darwin-arm64': 2.6.0 + '@parcel/watcher-darwin-x64': 2.6.0 + '@parcel/watcher-freebsd-x64': 2.6.0 + '@parcel/watcher-linux-arm-glibc': 2.6.0 + '@parcel/watcher-linux-arm-musl': 2.6.0 + '@parcel/watcher-linux-arm64-glibc': 2.6.0 + '@parcel/watcher-linux-arm64-musl': 2.6.0 + '@parcel/watcher-linux-x64-glibc': 2.6.0 + '@parcel/watcher-linux-x64-musl': 2.6.0 + '@parcel/watcher-win32-arm64': 2.6.0 + '@parcel/watcher-win32-x64': 2.6.0 + + '@rolldown/binding-android-arm64@1.1.5': + optional: true + + '@rolldown/binding-darwin-arm64@1.1.5': + optional: true + + '@rolldown/binding-darwin-x64@1.1.5': + optional: true + + '@rolldown/binding-freebsd-x64@1.1.5': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.1.5': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-musl@1.1.5': + optional: true + + '@rolldown/binding-openharmony-arm64@1.1.5': + optional: true + + '@rolldown/binding-wasm32-wasi@1.1.5': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.2.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.1.5': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.1.5': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@schummar/icu-type-parser@1.21.5': {} + + '@standard-schema/spec@1.1.0': {} + + '@swc/core-darwin-arm64@1.15.47': + optional: true + + '@swc/core-darwin-x64@1.15.47': + optional: true + + '@swc/core-linux-arm-gnueabihf@1.15.47': + optional: true + + '@swc/core-linux-arm64-gnu@1.15.47': + optional: true + + '@swc/core-linux-arm64-musl@1.15.47': + optional: true + + '@swc/core-linux-ppc64-gnu@1.15.47': + optional: true + + '@swc/core-linux-s390x-gnu@1.15.47': + optional: true + + '@swc/core-linux-x64-gnu@1.15.47': + optional: true + + '@swc/core-linux-x64-musl@1.15.47': + optional: true + + '@swc/core-win32-arm64-msvc@1.15.47': + optional: true + + '@swc/core-win32-ia32-msvc@1.15.47': + optional: true + + '@swc/core-win32-x64-msvc@1.15.47': + optional: true + + '@swc/core@1.15.47': + dependencies: + '@swc/counter': 0.1.3 + '@swc/types': 0.1.27 + optionalDependencies: + '@swc/core-darwin-arm64': 1.15.47 + '@swc/core-darwin-x64': 1.15.47 + '@swc/core-linux-arm-gnueabihf': 1.15.47 + '@swc/core-linux-arm64-gnu': 1.15.47 + '@swc/core-linux-arm64-musl': 1.15.47 + '@swc/core-linux-ppc64-gnu': 1.15.47 + '@swc/core-linux-s390x-gnu': 1.15.47 + '@swc/core-linux-x64-gnu': 1.15.47 + '@swc/core-linux-x64-musl': 1.15.47 + '@swc/core-win32-arm64-msvc': 1.15.47 + '@swc/core-win32-ia32-msvc': 1.15.47 + '@swc/core-win32-x64-msvc': 1.15.47 + + '@swc/counter@0.1.3': {} + + '@swc/helpers@0.5.15': + dependencies: + tslib: 2.8.1 + + '@swc/types@0.1.27': + dependencies: + '@swc/counter': 0.1.3 + + '@tailwindcss/node@4.3.3': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.24.4 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.3 + + '@tailwindcss/oxide-android-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide@4.3.3': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-x64': 4.3.3 + '@tailwindcss/oxide-freebsd-x64': 4.3.3 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.3 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-x64-musl': 4.3.3 + '@tailwindcss/oxide-wasm32-wasi': 4.3.3 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 + + '@tailwindcss/postcss@4.3.3': + dependencies: + '@alloc/quick-lru': 5.2.0 + '@tailwindcss/node': 4.3.3 + '@tailwindcss/oxide': 4.3.3 + postcss: 8.5.25 + tailwindcss: 4.3.3 + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@26.1.2': + dependencies: + undici-types: 8.3.0 + + '@types/nodemailer@8.0.1': + dependencies: + '@types/node': 26.1.2 + + '@types/pg@8.20.0': + dependencies: + '@types/node': 26.1.2 + pg-protocol: 1.15.0 + pg-types: 2.2.0 + + '@types/react-dom@19.2.3(@types/react@19.2.17)': + dependencies: + '@types/react': 19.2.17 + + '@types/react@19.2.17': + dependencies: + csstype: 3.2.3 + + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.10(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1) + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + + assertion-error@2.0.1: {} + + baseline-browser-mapping@2.11.7: {} + + better-auth@1.6.25(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0))(next@16.2.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.10(@types/node@26.1.2)(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1))): + dependencies: + '@better-auth/core': 1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2) + '@better-auth/drizzle-adapter': 1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0)) + '@better-auth/kysely-adapter': 1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)(kysely@0.29.4) + '@better-auth/memory-adapter': 1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2) + '@better-auth/mongo-adapter': 1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2) + '@better-auth/prisma-adapter': 1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2) + '@better-auth/telemetry': 1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + '@noble/ciphers': 2.2.0 + '@noble/hashes': 2.2.0 + better-call: 1.3.7(zod@4.4.3) + defu: 6.1.7 + jose: 6.2.5 + kysely: 0.29.4 + nanostores: 1.4.2 + zod: 4.4.3 + optionalDependencies: + drizzle-kit: 0.31.10 + drizzle-orm: 0.45.2(@types/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0) + next: 16.2.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + pg: 8.22.0 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + vitest: 4.1.10(@types/node@26.1.2)(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) + transitivePeerDependencies: + - '@cloudflare/workers-types' + - '@opentelemetry/api' + + better-call@1.3.7(zod@4.4.3): + dependencies: + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + rou3: 0.7.12 + set-cookie-parser: 3.1.2 + optionalDependencies: + zod: 4.4.3 + + buffer-from@1.1.2: {} + + caniuse-lite@1.0.30001806: {} + + chai@6.2.2: {} + + client-only@0.0.1: {} + + convert-source-map@2.0.0: {} + + csstype@3.2.3: {} + + defu@6.1.7: {} + + detect-libc@2.1.2: {} + + dotenv@17.4.2: {} + + drizzle-kit@0.31.10: + dependencies: + '@drizzle-team/brocli': 0.10.2 + '@esbuild-kit/esm-loader': 2.6.5 + esbuild: 0.25.12 + tsx: 4.23.1 + + drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0): + optionalDependencies: + '@types/pg': 8.20.0 + kysely: 0.29.4 + pg: 8.22.0 + + enhanced-resolve@5.24.4: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + es-module-lexer@2.3.1: {} + + esbuild@0.18.20: + optionalDependencies: + '@esbuild/android-arm': 0.18.20 + '@esbuild/android-arm64': 0.18.20 + '@esbuild/android-x64': 0.18.20 + '@esbuild/darwin-arm64': 0.18.20 + '@esbuild/darwin-x64': 0.18.20 + '@esbuild/freebsd-arm64': 0.18.20 + '@esbuild/freebsd-x64': 0.18.20 + '@esbuild/linux-arm': 0.18.20 + '@esbuild/linux-arm64': 0.18.20 + '@esbuild/linux-ia32': 0.18.20 + '@esbuild/linux-loong64': 0.18.20 + '@esbuild/linux-mips64el': 0.18.20 + '@esbuild/linux-ppc64': 0.18.20 + '@esbuild/linux-riscv64': 0.18.20 + '@esbuild/linux-s390x': 0.18.20 + '@esbuild/linux-x64': 0.18.20 + '@esbuild/netbsd-x64': 0.18.20 + '@esbuild/openbsd-x64': 0.18.20 + '@esbuild/sunos-x64': 0.18.20 + '@esbuild/win32-arm64': 0.18.20 + '@esbuild/win32-ia32': 0.18.20 + '@esbuild/win32-x64': 0.18.20 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fsevents@2.3.3: + optional: true + + get-tsconfig@4.14.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + graceful-fs@4.2.11: {} + + icu-minify@4.13.4: + dependencies: + '@formatjs/icu-messageformat-parser': 3.5.15 + + intl-messageformat@11.2.12: + dependencies: + '@formatjs/fast-memoize': 3.1.7 + '@formatjs/icu-messageformat-parser': 3.5.15 + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + jiti@2.7.0: {} + + jose@6.2.5: {} + + kysely@0.29.4: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + nanoid@3.3.16: {} + + nanostores@1.4.2: {} + + negotiator@1.0.0: {} + + next-intl-swc-plugin-extractor@4.13.4: {} + + next-intl@4.13.4(next@16.2.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(typescript@7.0.2): + dependencies: + '@formatjs/intl-localematcher': 0.8.13 + '@parcel/watcher': 2.6.0 + '@swc/core': 1.15.47 + icu-minify: 4.13.4 + negotiator: 1.0.0 + next: 16.2.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + next-intl-swc-plugin-extractor: 4.13.4 + po-parser: 2.1.1 + react: 19.2.8 + use-intl: 4.13.4(react@19.2.8) + optionalDependencies: + typescript: 7.0.2 + transitivePeerDependencies: + - '@swc/helpers' + + next@16.2.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + '@next/env': 16.2.12 + '@swc/helpers': 0.5.15 + baseline-browser-mapping: 2.11.7 + caniuse-lite: 1.0.30001806 + postcss: 8.4.31 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + styled-jsx: 5.1.6(react@19.2.8) + optionalDependencies: + '@next/swc-darwin-arm64': 16.2.12 + '@next/swc-darwin-x64': 16.2.12 + '@next/swc-linux-arm64-gnu': 16.2.12 + '@next/swc-linux-arm64-musl': 16.2.12 + '@next/swc-linux-x64-gnu': 16.2.12 + '@next/swc-linux-x64-musl': 16.2.12 + '@next/swc-win32-arm64-msvc': 16.2.12 + '@next/swc-win32-x64-msvc': 16.2.12 + sharp: 0.34.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + + node-addon-api@7.1.1: {} + + nodemailer@9.0.3: {} + + obug@2.1.4: {} + + overlayscrollbars-react@0.5.6(overlayscrollbars@2.16.0)(react@19.2.8): + dependencies: + overlayscrollbars: 2.16.0 + react: 19.2.8 + + overlayscrollbars@2.16.0: {} + + pathe@2.0.3: {} + + pg-cloudflare@1.4.0: + optional: true + + pg-connection-string@2.14.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.14.0(pg@8.22.0): + dependencies: + pg: 8.22.0 + + pg-protocol@1.15.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.22.0: + dependencies: + pg-connection-string: 2.14.0 + pg-pool: 3.14.0(pg@8.22.0) + pg-protocol: 1.15.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.4.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + po-parser@2.1.1: {} + + postcss@8.4.31: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postcss@8.5.25: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + + react-dom@19.2.8(react@19.2.8): + dependencies: + react: 19.2.8 + scheduler: 0.27.0 + + react-icons@5.7.0(react@19.2.8): + dependencies: + react: 19.2.8 + + react@19.2.8: {} + + resolve-pkg-maps@1.0.0: {} + + rolldown@1.1.5: + dependencies: + '@oxc-project/types': 0.139.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.1.5 + '@rolldown/binding-darwin-arm64': 1.1.5 + '@rolldown/binding-darwin-x64': 1.1.5 + '@rolldown/binding-freebsd-x64': 1.1.5 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.5 + '@rolldown/binding-linux-arm64-gnu': 1.1.5 + '@rolldown/binding-linux-arm64-musl': 1.1.5 + '@rolldown/binding-linux-ppc64-gnu': 1.1.5 + '@rolldown/binding-linux-s390x-gnu': 1.1.5 + '@rolldown/binding-linux-x64-gnu': 1.1.5 + '@rolldown/binding-linux-x64-musl': 1.1.5 + '@rolldown/binding-openharmony-arm64': 1.1.5 + '@rolldown/binding-wasm32-wasi': 1.1.5 + '@rolldown/binding-win32-arm64-msvc': 1.1.5 + '@rolldown/binding-win32-x64-msvc': 1.1.5 + + rou3@0.7.12: {} + + scheduler@0.27.0: {} + + semver@7.8.5: {} + + set-cookie-parser@3.1.2: {} + + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + optional: true + + sharp@0.35.3(@types/node@26.1.2): + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.3 + '@img/sharp-darwin-x64': 0.35.3 + '@img/sharp-freebsd-wasm32': 0.35.3 + '@img/sharp-libvips-darwin-arm64': 1.3.2 + '@img/sharp-libvips-darwin-x64': 1.3.2 + '@img/sharp-libvips-linux-arm': 1.3.2 + '@img/sharp-libvips-linux-arm64': 1.3.2 + '@img/sharp-libvips-linux-ppc64': 1.3.2 + '@img/sharp-libvips-linux-riscv64': 1.3.2 + '@img/sharp-libvips-linux-s390x': 1.3.2 + '@img/sharp-libvips-linux-x64': 1.3.2 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + '@img/sharp-linux-arm': 0.35.3 + '@img/sharp-linux-arm64': 0.35.3 + '@img/sharp-linux-ppc64': 0.35.3 + '@img/sharp-linux-riscv64': 0.35.3 + '@img/sharp-linux-s390x': 0.35.3 + '@img/sharp-linux-x64': 0.35.3 + '@img/sharp-linuxmusl-arm64': 0.35.3 + '@img/sharp-linuxmusl-x64': 0.35.3 + '@img/sharp-webcontainers-wasm32': 0.35.3 + '@img/sharp-win32-arm64': 0.35.3 + '@img/sharp-win32-ia32': 0.35.3 + '@img/sharp-win32-x64': 0.35.3 + '@types/node': 26.1.2 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + split2@4.2.0: {} + + stackback@0.0.2: {} + + std-env@4.2.0: {} + + styled-jsx@5.1.6(react@19.2.8): + dependencies: + client-only: 0.0.1 + react: 19.2.8 + + tailwindcss@4.3.3: {} + + tapable@2.3.3: {} + + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinyrainbow@3.1.1: {} + + tslib@2.8.1: {} + + tsx@4.23.1: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + + undici-types@8.3.0: {} + + use-intl@4.13.4(react@19.2.8): + dependencies: + '@formatjs/fast-memoize': 3.1.7 + '@schummar/icu-type-parser': 1.21.5 + icu-minify: 4.13.4 + intl-messageformat: 11.2.12 + react: 19.2.8 + + vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.25 + rolldown: 1.1.5 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.1.2 + esbuild: 0.28.1 + fsevents: 2.3.3 + jiti: 2.7.0 + tsx: 4.23.1 + + vitest@4.1.10(@types/node@26.1.2)(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.1.2 + transitivePeerDependencies: + - msw + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + xtend@4.0.2: {} + + zod@4.4.3: {} diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 0000000..d386924 --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1,3 @@ +export default { + plugins: { '@tailwindcss/postcss': {} }, +} diff --git a/scripts/create-admin.ts b/scripts/create-admin.ts new file mode 100644 index 0000000..df24a83 --- /dev/null +++ b/scripts/create-admin.ts @@ -0,0 +1,76 @@ +import 'dotenv/config' +import { pool } from '~/data/db' +import { auth, minPasswordLength } from '~/lib/auth' + +type Outcome = 'created' | 'updated' + +function readEnv(name: string): string { + const value = process.env[name]?.trim() + + if (!value) { + throw new Error(`${name} fehlt. Aufruf: ${name}=... pnpm admin:create`) + } + + return value +} + +export async function createAdmin(email: string, password: string, name: string): Promise { + if (password.length < minPasswordLength) { + throw new Error(`ADMIN_PASSWORD ist zu kurz. Es braucht mindestens ${minPasswordLength} Zeichen.`) + } + + const context = await auth.$context + const hashed = await context.password.hash(password) + const existing = await context.internalAdapter.findUserByEmail(email, { includeAccounts: true }) + + if (!existing) { + const user = await context.internalAdapter.createUser({ email, name, emailVerified: true, isAdmin: true }) + + await context.internalAdapter.createAccount({ + userId: user.id, + providerId: 'credential', + accountId: user.id, + password: hashed, + }) + + return 'created' + } + + const credential = existing.accounts.find(account => account.providerId === 'credential') + + if (credential) { + await context.internalAdapter.updatePassword(existing.user.id, hashed) + } else { + await context.internalAdapter.createAccount({ + userId: existing.user.id, + providerId: 'credential', + accountId: existing.user.id, + password: hashed, + }) + } + + await context.internalAdapter.updateUser(existing.user.id, { isAdmin: true }) + + return 'updated' +} + +async function main(): Promise { + const email = readEnv('ADMIN_EMAIL') + const password = readEnv('ADMIN_PASSWORD') + const name = process.env.ADMIN_NAME?.trim() || email.split('@')[0] || email + + const outcome = await createAdmin(email, password, name) + + if (outcome === 'created') { + console.log(`Admin angelegt: ${email}. Anmeldung ab sofort unter /login möglich.`) + } else { + console.log(`Admin aktualisiert: ${email}. Passwort neu gesetzt, Verwaltungsrecht bestätigt.`) + } +} + +main() + .catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 + }) + .finally(() => pool.end()) diff --git a/scripts/publish-due.ts b/scripts/publish-due.ts new file mode 100644 index 0000000..431a8af --- /dev/null +++ b/scripts/publish-due.ts @@ -0,0 +1,25 @@ +import 'dotenv/config' +import { pool } from '~/data/db' +import { publishDueEntries } from '~/lib/publish-due' + +async function main(): Promise { + const { published } = await publishDueEntries() + + if (published.length === 0) { + console.log('Kein terminierter Beitrag ist fällig.') + return + } + + for (const entry of published) { + console.log(`Veröffentlicht: ${entry.number} ${entry.slug} (${entry.title})`) + } + + console.log(`${published.length} Beiträge veröffentlicht.`) +} + +main() + .catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 + }) + .finally(() => pool.end()) diff --git a/scripts/sample-images.ts b/scripts/sample-images.ts new file mode 100644 index 0000000..e0c5acf --- /dev/null +++ b/scripts/sample-images.ts @@ -0,0 +1,307 @@ +import { findMedia } from '~/data/repositories/media' +import { renderPng, renderVariant } from '~/lib/media-images' +import { uploadMedia } from '~/lib/media-upload' +import { getStorage, objectPath } from '~/lib/storage' + +export const sampleWidth = 2000 + +export const sampleHeight = 1125 + +export type SampleImage = { + key: string + id: string + project: string + filename: string + alt: string + caption: string + svg: () => string +} + +function rng(seed: number): () => number { + let state = seed % 4294967296 + + return () => { + state = (state * 1664525 + 1013904223) % 4294967296 + + return state / 4294967296 + } +} + +function round(value: number): number { + return Math.round(value * 100) / 100 +} + +function frame(body: string, tint: string): string { + return [ + ``, + '', + ``, + '', + body, + '', + ].join('') +} + +function ruleGrid(): string { + const lines: string[] = [] + + for (let y = 150; y < 1040; y += 74) { + lines.push(``) + } + + return lines.join('') +} + +function ruleEngine(): string { + const tint = '#2e7d5b' + const parts: string[] = [ruleGrid()] + + for (let index = 0; index < 3; index += 1) { + const y = 240 + index * 250 + + parts.push(``) + parts.push(``) + parts.push(``) + + parts.push(``) + parts.push(``) + + parts.push( + ``, + ) + parts.push(``) + } + + return frame(parts.join(''), tint) +} + +function columnMenu(): string { + const tint = '#2b4a9b' + const parts: string[] = [] + const columns = [180, 560, 940, 1320, 1620] + const widths = [340, 340, 340, 260, 220] + + parts.push('') + + columns.forEach((x, column) => { + const width = widths[column] ?? 300 + + parts.push(``) + + for (let row = 0; row < 9; row += 1) { + const y = 260 + row * 84 + const bar = Math.round(width * (0.45 + ((row + column) % 4) * 0.13)) + const fill = column === 3 ? tint : '#dedcd3' + const opacity = column === 3 ? round(0.85 - (row % 4) * 0.15) : 1 + + parts.push(``) + } + }) + + for (let row = 0; row < 9; row += 1) { + const y = 244 + row * 84 + + parts.push(``) + } + + parts.push(``) + + for (let entry = 0; entry < 4; entry += 1) { + parts.push(``) + } + + return frame(parts.join(''), tint) +} + +function trackerMap(): string { + const tint = '#b4761a' + const parts: string[] = [] + const random = rng(20260730) + + for (let x = 180; x < 1880; x += 106) { + parts.push(``) + } + + for (let y = 150; y < 1020; y += 96) { + parts.push(``) + } + + for (let index = 0; index < 220; index += 1) { + const x = round(160 + random() * 1700) + const y = round(140 + random() * 860) + const radius = round(3 + random() * 4) + + parts.push(``) + } + + const clusters = [ + { x: 520, y: 400, size: 96 }, + { x: 1180, y: 620, size: 132 }, + { x: 1560, y: 320, size: 74 }, + ] + + for (const cluster of clusters) { + parts.push(``) + parts.push(``) + parts.push(``) + } + + parts.push(``) + + return frame(parts.join(''), tint) +} + +function slaClock(): string { + const tint = '#2e7d5b' + const parts: string[] = [] + const pauses = [ + { x: 700, width: 220 }, + { x: 1420, width: 220 }, + ] + + for (const pause of pauses) { + parts.push(``) + } + + for (let index = 0; index < 7; index += 1) { + const y = 250 + index * 110 + const start = 180 + index * 42 + const width = 420 + ((index * 7) % 5) * 190 + + parts.push(``) + parts.push(``) + parts.push(``) + } + + parts.push('') + + for (let tick = 0; tick < 12; tick += 1) { + parts.push(``) + } + + return frame(parts.join(''), tint) +} + +function inbox(): string { + const tint = '#6b4ba8' + const parts: string[] = [] + const lanes = [200, 700, 1200] + + lanes.forEach((x, lane) => { + for (let row = 0; row < 4; row += 1) { + const y = 210 + row * 120 + + parts.push(``) + parts.push(``) + parts.push(``) + parts.push(``) + } + + parts.push( + ``, + ) + }) + + parts.push(``) + parts.push('') + parts.push('') + + return frame(parts.join(''), tint) +} + +export const sampleImages: SampleImage[] = [ + { + key: 'regel-engine', + id: '0b6f0f4a-1c1f-4a51-9b2f-1d0a41f00001', + project: 'trakk', + filename: 'regel-engine-bedingung-aktion.png', + alt: 'Schema der Regel-Engine: links drei Bedingungen, rechts drei Aktionen, jeweils mit einer Linie verbunden', + caption: 'Jede Bedingung zeigt auf genau eine Aktion.', + svg: ruleEngine, + }, + { + key: 'spaltenmenue', + id: '0b6f0f4a-1c1f-4a51-9b2f-1d0a41f00002', + project: 'mta360', + filename: 'spaltenmenue-rechtsklick.png', + alt: 'Tabelle mit fünf Spalten, über der vierten Spalte ist ein Menü mit vier Einträgen geöffnet', + caption: 'Das Spaltenmenü öffnet sich über der Spalte, die es betrifft.', + svg: columnMenu, + }, + { + key: 'tracker-karte', + id: '0b6f0f4a-1c1f-4a51-9b2f-1d0a41f00003', + project: 'mta-telematik', + filename: 'tracker-karte-cluster.png', + alt: 'Kartenausschnitt mit vielen kleinen Punkten und drei hervorgehobenen Ballungen, die mit einer gestrichelten Linie verbunden sind', + caption: 'Dicht beieinander liegende Tracker werden zu Ballungen zusammengefasst.', + svg: trackerMap, + }, + { + key: 'sla-uhr', + id: '0b6f0f4a-1c1f-4a51-9b2f-1d0a41f00004', + project: 'trakk', + filename: 'sla-uhr-pausiert.png', + alt: 'Zeitleiste mit sieben Balken, zwei helle senkrechte Bänder markieren die Zeiten, in denen die Uhr steht', + caption: 'Die hellen Bänder sind Wochenenden und Feiertage, dort läuft die Uhr nicht.', + svg: slaClock, + }, + { + key: 'eingang', + id: '0b6f0f4a-1c1f-4a51-9b2f-1d0a41f00005', + project: 'orbit', + filename: 'kommentare-ein-eingang.png', + alt: 'Drei Spalten mit Kommentaren laufen über geschwungene Linien in einen gemeinsamen Eingang unten zusammen', + caption: 'Drei Kanäle, ein Eingang.', + svg: inbox, + }, +] + +export async function ensureSampleMedia(projectIds: Map): Promise> { + const storage = getStorage() + const ids = new Map() + + for (const sample of sampleImages) { + const projectId = projectIds.get(sample.project) + + if (!projectId) { + continue + } + + ids.set(sample.key, sample.id) + + const existing = await findMedia(sample.id) + + if (existing) { + if (!(await storage.exists(existing.path))) { + const body = await renderPng(sample.svg()) + + await storage.put(existing.path, body, 'image/png') + + for (const variant of existing.variants) { + const rendered = await renderVariant(body, variant.width) + + await storage.put(variant.path, rendered.body, 'image/webp') + } + } + + continue + } + + const path = objectPath(projectId, sample.id, 'original.png') + const stored = await storage.read(path) + const body = stored ? stored.body : await renderPng(sample.svg()) + + await uploadMedia({ + id: sample.id, + projectId, + filename: sample.filename, + body, + alt: sample.alt, + caption: sample.caption, + reuse: true, + }) + } + + return ids +} diff --git a/scripts/seed.ts b/scripts/seed.ts new file mode 100644 index 0000000..0694d05 --- /dev/null +++ b/scripts/seed.ts @@ -0,0 +1,163 @@ +import 'dotenv/config' +import { eq } from 'drizzle-orm' +import { db, pool } from '~/data/db' +import { apiClients, blocks, brands, postTypes, posts, projects } from '~/data/schema' +import { defaultPostTypes } from '~/domain/post-type' +import { hashToken } from '~/lib/api-auth' +import { ensureSampleMedia } from './sample-images' + +const brandRows = [ + { slug: 'nyo', name: 'NYO', color: '#2b4a9b', sort: 1 }, + { slug: 'pocket-rocket', name: 'Pocket Rocket', color: '#c43a22', sort: 2 }, +] + +const projectRows = [ + { brand: 'pocket-rocket', slug: 'trakk', name: 'Trakk', code: 'TRK', color: '#2e7d5b', sort: 1 }, + { brand: 'nyo', slug: 'mta360', name: 'MTA360', code: 'MTA', color: '#2b4a9b', sort: 2 }, + { brand: 'nyo', slug: 'mta-telematik', name: 'MTA Telematik', code: 'TEL', color: '#b4761a', sort: 3 }, + { brand: 'pocket-rocket', slug: 'orbit', name: 'Orbit', code: 'ORB', color: '#6b4ba8', sort: 4 }, + { brand: 'nyo', slug: 'pulsar', name: 'Pulsar', code: 'PLS', color: '#a33b6a', sort: 5 }, +] + +const postRows = [ + { + project: 'trakk', number: 142, slug: 'regel-engine-bedingung-trifft-aktion', + title: 'Regel-Engine: Bedingung trifft Aktion', type: 'feature' as const, audience: 'customer' as const, + teaser: 'Tickets reagieren jetzt selbst. Du setzt eine Bedingung und hängst eine Aktion daran: Status ändern, zuweisen, Nachricht schicken. Läuft über den Event-Bus im Worker, ohne Cronjobs.', + publishAt: new Date('2026-07-30T07:14:00Z'), author: 'Paul', media: 'regel-engine', + body: [ + { type: 'text', data: { text: 'Bisher war eine Regel im Grunde ein Cronjob mit einer Datenbankabfrage darin. Wer sie ändern wollte, brauchte einen Entwickler und ein Deployment. Das ist vorbei: eine Regel besteht jetzt aus einer Bedingung und einer Aktion, beides im Ticketsystem selbst zusammengeklickt.' } }, + { type: 'text', data: { text: 'Bedingungen greifen auf alles zu, was ein Ticket ausmacht: Status, Priorität, Kunde, Kategorie, Zeit seit der letzten Antwort. Aktionen setzen den Status, weisen zu, hängen ein Schlagwort an oder schicken eine Nachricht. Mehrere Aktionen an einer Bedingung sind erlaubt und laufen in der Reihenfolge, in der sie stehen.' } }, + { type: 'callout', data: { text: 'Regeln greifen ab sofort auch rückwirkend auf offene Tickets. Wer das nicht will, setzt beim Anlegen den Haken für neue Tickets.', tone: 'info' } }, + { type: 'text', data: { text: 'Technisch hängt das Ganze am Event-Bus im Worker. Jede Zustandsänderung an einem Ticket erzeugt ein Ereignis, die Regel-Engine hört mit und prüft nur die Regeln, die für dieses Ereignis überhaupt in Frage kommen. Damit bleibt die Last berechenbar, auch wenn ein Kunde hundert Regeln anlegt.' } }, + ], + }, + { + project: 'mta360', number: 141, slug: 'spalten-per-rechtsklick-verwalten', + title: 'Spalten per Rechtsklick verwalten', type: 'improvement' as const, audience: 'customer' as const, + teaser: 'Rechtsklick auf eine Kopfzeile öffnet das Spaltenmenü: sortieren, anpinnen, ausblenden, Breite zurücksetzen. Wer das nicht mag, schaltet es in den Einstellungen ab.', + publishAt: new Date('2026-07-29T09:00:00Z'), author: 'Matthias', media: 'spaltenmenue', + body: [ + { type: 'text', data: { text: 'Das Spaltenmenü saß bisher hinter einem Zahnrad über der Tabelle, drei Klicks von der Spalte entfernt, die man eigentlich meinte. Jetzt sitzt es dort, wo man ohnehin hinschaut: auf der Kopfzeile selbst.' } }, + { type: 'text', data: { text: 'Im Menü liegen Sortierung, Anpinnen nach links oder rechts, Ausblenden und Breite zurücksetzen. Die Auswahl gilt sofort und wird in der Ansicht gespeichert, nicht im Browser. Wer dieselbe Ansicht auf einem anderen Rechner öffnet, findet seine Spalten wieder.' } }, + { type: 'callout', data: { text: 'Wer lieber ohne Rechtsklick arbeitet, schaltet das Menü in den Einstellungen unter Datentabelle ab. Der Weg über die Kopfzeilen-Schaltfläche bleibt bestehen.', tone: 'info' } }, + ], + }, + { + project: 'mta-telematik', number: 140, slug: 'zehntausend-tracker-auf-einer-karte', + title: '10.000 Tracker auf einer Karte', type: 'feature' as const, audience: 'customer' as const, + teaser: 'Positionen kommen gebündelt aus TimescaleDB, Geofences werden serverseitig geprüft. Die Karte bleibt bei voller Flotte flüssig.', + publishAt: new Date('2026-07-28T10:30:00Z'), author: 'Paul', media: 'tracker-karte', + body: [ + { type: 'text', data: { text: 'Zehntausend Fahrzeuge gleichzeitig auf eine Karte zu legen scheiterte bisher an zwei Stellen: die Abfrage holte jede Position einzeln, und der Browser versuchte, zehntausend Marker zu zeichnen. Beides ist gelöst.' } }, + { type: 'text', data: { text: 'Die Positionen kommen jetzt gebündelt aus TimescaleDB, vorverdichtet auf die Zoomstufe, die tatsächlich sichtbar ist. Was nah beieinander liegt, wird serverseitig zu einer Gruppe zusammengefasst und erst beim Hineinzoomen aufgelöst.' } }, + { type: 'text', data: { text: 'Geofences werden ebenfalls serverseitig geprüft, über PostGIS. Ein Fahrzeug, das eine Zone verlässt, erzeugt ein Ereignis, ohne dass ein Browser offen sein muss. Das war vorher Aufgabe des Frontends und funktionierte nur, solange jemand hinschaute.' } }, + ], + }, + { + project: 'trakk', number: 139, slug: 'sla-uhr-zaehlt-feiertage-nicht-mehr-mit', + title: 'SLA-Uhr zählt Feiertage nicht mehr mit', type: 'fix' as const, audience: 'customer' as const, + teaser: 'Reaktionszeiten liefen über Wochenenden und Feiertage weiter und lösten falsche Eskalationen aus. Der Kalender pro Kunde gilt jetzt auch für die Uhr.', + publishAt: new Date('2026-07-25T08:00:00Z'), author: 'Matthias', media: 'sla-uhr', + body: [ + { type: 'text', data: { text: 'Ein Ticket, das freitags um 17 Uhr aufschlug, war montags früh eskaliert, obwohl niemand hätte antworten können. Grund war eine SLA-Uhr, die schlicht durchlief.' } }, + { type: 'text', data: { text: 'Die Uhr pausiert jetzt außerhalb der Geschäftszeiten und an den Feiertagen, die am Kunden hinterlegt sind. Läuft ein Ticket über ein Wochenende, steht die Uhr still und läuft am Montag zur ersten Geschäftsstunde weiter.' } }, + { type: 'callout', data: { text: 'Bereits laufende Fristen wurden einmalig neu berechnet. Es kann sein, dass ein Ticket dadurch aus der Eskalation gefallen ist.', tone: 'caution' } }, + ], + }, + { + project: 'mta360', number: 138, slug: 'lokale-tabellen-konfigurationen-entfallen', + title: 'Lokale Tabellen-Konfigurationen werden nicht mehr gelesen', type: 'breaking' as const, audience: 'customer' as const, + teaser: 'Ansichten liegen jetzt vollständig in der Datenbank. Wer noch eine Konfiguration im Browser hatte, sieht die Standardansicht und legt seine Ansicht einmal neu an.', + publishAt: new Date('2026-07-23T12:00:00Z'), author: 'Matthias', media: undefined, + body: [ + { type: 'text', data: { text: 'Tabellenansichten lagen historisch an zwei Orten: in der Datenbank und zusätzlich im Browser. Wer an zwei Rechnern arbeitete, hatte zwei Wahrheiten, und welche gewann, war schwer vorherzusagen.' } }, + { type: 'text', data: { text: 'Ab dieser Version wird ausschließlich gelesen, was in der Datenbank steht. Die lokale Kopie wird beim ersten Start verworfen.' } }, + { type: 'callout', data: { text: 'Wer eine Ansicht nur lokal gepflegt hatte, sieht einmalig die Standardansicht und legt seine Ansicht neu an. Danach gilt sie auf allen Geräten.', tone: 'caution' } }, + ], + }, + { + project: 'orbit', number: 137, slug: 'ein-eingang-fuer-alle-kommentare', + title: 'Ein Eingang für alle Kommentare', type: 'info' as const, audience: 'internal' as const, + teaser: 'Kommentare aus Instagram, LinkedIn und Facebook landen in einem Eingang, Antworten gehen von dort zurück.', + publishAt: new Date('2026-07-21T15:45:00Z'), author: 'Paul', media: 'eingang', + body: [ + { type: 'text', data: { text: 'Kommentare kamen bisher dort an, wo sie geschrieben wurden: bei Instagram, bei LinkedIn, bei Facebook. Wer sie beantworten wollte, brauchte drei offene Fenster und ein gutes Gedächtnis.' } }, + { type: 'text', data: { text: 'Jetzt laufen alle Kommentare in einen Eingang. Antworten gehen von dort auf demselben Weg zurück, auf dem sie gekommen sind. Wer antwortet und wann, steht am Kommentar.' } }, + { type: 'text', data: { text: 'Der Eingang ist bewusst schlicht gehalten: ungelesen zuerst, danach nach Alter. Zuweisen und Erledigen gibt es, mehr nicht. Alles Weitere kommt erst, wenn wir wissen, ob es fehlt.' } }, + ], + }, +] + +export async function seed(): Promise { + const brandIds = new Map() + const projectIds = new Map() + for (const row of defaultPostTypes) { + await db.insert(postTypes).values(row).onConflictDoNothing({ target: postTypes.key }) + } + + const typeRows = await db.select({ id: postTypes.id, key: postTypes.key }).from(postTypes) + const typeIds = new Map(typeRows.map(row => [row.key, row.id])) + + for (const row of brandRows) { + const [brand] = await db.insert(brands).values(row) + .onConflictDoUpdate({ target: brands.slug, set: { name: row.name, color: row.color, sort: row.sort } }) + .returning() + brandIds.set(row.slug, brand!.id) + } + + for (const row of projectRows) { + const [project] = await db.insert(projects) + .values({ brandId: brandIds.get(row.brand)!, slug: row.slug, name: row.name, code: row.code, color: row.color, sort: row.sort }) + .onConflictDoUpdate({ target: projects.slug, set: { name: row.name, code: row.code, color: row.color, sort: row.sort } }) + .returning() + projectIds.set(row.slug, project!.id) + } + + const mediaIds = await ensureSampleMedia(projectIds) + + for (const row of postRows) { + const coverMediaId = row.media === undefined ? null : mediaIds.get(row.media) ?? null + const [post] = await db.insert(posts) + .values({ + projectId: projectIds.get(row.project)!, + number: row.number, + slug: row.slug, + title: row.title, + teaser: row.teaser, + typeId: typeIds.get(row.type)!, + audience: row.audience, + status: 'published', + publishAt: row.publishAt, + authorName: row.author, + coverMediaId, + }) + .onConflictDoUpdate({ + target: [posts.projectId, posts.slug], + set: { title: row.title, teaser: row.teaser, typeId: typeIds.get(row.type)!, coverMediaId }, + }) + .returning() + + await db.delete(blocks).where(eq(blocks.postId, post!.id)) + await db.insert(blocks).values(row.body.map((entry, index) => ({ + postId: post!.id, + sort: index, + type: entry.type, + data: entry.data, + }))) + + } + + await db.insert(apiClients).values({ + name: 'Trakk Leseclient', + tokenHash: hashToken('lb_seed_trakk_read'), + mode: 'read', + scope: 'customer', + projectId: projectIds.get('trakk')!, + }).onConflictDoNothing() +} + +if (process.argv[1]?.endsWith('seed.ts')) { + await seed() + await pool.end() +} diff --git a/src/app/(site)/[project]/[entry]/[month]/page.tsx b/src/app/(site)/[project]/[entry]/[month]/page.tsx new file mode 100644 index 0000000..adeef8f --- /dev/null +++ b/src/app/(site)/[project]/[entry]/[month]/page.tsx @@ -0,0 +1,88 @@ +import type { Metadata } from 'next' +import { notFound } from 'next/navigation' +import { getFormatter, getTranslations } from 'next-intl/server' +import { ProjectArchive } from '~/components/archive/ProjectArchive' +import { + listArchiveMonths, + listPostTypeCounts, + listPostsForArchive, + loadArchiveStats, + loadHighestNumber, + loadPostCovers, +} from '~/data/repositories/archive' +import { monthYearFormat } from '~/lib/dates' +import { findProjectView } from '~/lib/project-view' +import { perPage, readMonth, readPage, readPostType, readYear, recentDays, type SearchParams } from '~/lib/query' +import type { ViewerScope } from '~/domain/types' + +const scope: ViewerScope = 'internal' + +const coverCount = 3 + +type Props = { + params: Promise<{ project: string, entry: string, month: string }> + searchParams: Promise +} + +export async function generateMetadata({ params }: Props): Promise { + const resolved = await params + const app = await getTranslations('app') + const year = readYear(resolved.entry) + const month = readMonth(resolved.month) + + if (year === undefined || month === undefined) { + const t = await getTranslations('notFound') + return { title: `${app('name')} - ${t('title')}` } + } + + const format = await getFormatter() + const view = await findProjectView(resolved.project, scope) + const period = format.dateTime(new Date(Date.UTC(year, month - 1, 1)), monthYearFormat) + + return { title: view ? `${period} - ${view.project.name} - ${app('name')}` : app('name') } +} + +export default async function MonthPage({ params, searchParams }: Props) { + const resolved = await params + const year = readYear(resolved.entry) + const month = readMonth(resolved.month) + + if (year === undefined || month === undefined) { + notFound() + } + + const query = await searchParams + const page = readPage(query.page) + const type = readPostType(query.type) + const view = await findProjectView(resolved.project, scope) + + if (!view) { + notFound() + } + + const [stats, months, list, typeCounts, highest] = await Promise.all([ + loadArchiveStats({ scope, projectSlug: resolved.project, recentDays }), + listArchiveMonths({ scope, projectSlug: resolved.project }), + listPostsForArchive({ scope, projectSlug: resolved.project, year, month, type, page, perPage }), + listPostTypeCounts({ scope, projectSlug: resolved.project, year, month }), + loadHighestNumber({ scope, projectSlug: resolved.project }), + ]) + + const covers = await loadPostCovers(list.items.slice(0, coverCount).map(item => item.id)) + + return ( + + ) +} diff --git a/src/app/(site)/[project]/[entry]/page.tsx b/src/app/(site)/[project]/[entry]/page.tsx new file mode 100644 index 0000000..8591a9f --- /dev/null +++ b/src/app/(site)/[project]/[entry]/page.tsx @@ -0,0 +1,99 @@ +import type { Metadata } from 'next' +import { notFound } from 'next/navigation' +import { getTranslations } from 'next-intl/server' +import { PostDetail } from '~/components/archive/PostDetail' +import { ProjectArchive } from '~/components/archive/ProjectArchive' +import { + findPostWithBlocks, + listArchiveMonths, + listPostTypeCounts, + listPostsForArchive, + loadArchiveStats, + loadHighestNumber, + loadPostCovers, +} from '~/data/repositories/archive' +import { findProjectView } from '~/lib/project-view' +import { perPage, readPage, readPostType, readYear, recentDays, type SearchParams } from '~/lib/query' +import type { ViewerScope } from '~/domain/types' + +const scope: ViewerScope = 'internal' + +const coverCount = 3 + +type Props = { + params: Promise<{ project: string, entry: string }> + searchParams: Promise +} + +export async function generateMetadata({ params }: Props): Promise { + const { project, entry } = await params + const app = await getTranslations('app') + const year = readYear(entry) + + if (year !== undefined) { + const view = await findProjectView(project, scope) + + return { title: view ? `${year} - ${view.project.name} - ${app('name')}` : app('name') } + } + + const detail = await findPostWithBlocks({ slug: entry, scope, projectSlug: project }) + + if (!detail) { + const t = await getTranslations('notFound') + return { title: `${app('name')} - ${t('title')}` } + } + + return { + title: `${detail.post.title} - ${detail.project.name}`, + description: detail.post.teaser ?? undefined, + } +} + +export default async function EntryPage({ params, searchParams }: Props) { + const { project, entry } = await params + const year = readYear(entry) + + if (year === undefined) { + const detail = await findPostWithBlocks({ slug: entry, scope, projectSlug: project }) + + if (!detail) { + notFound() + } + + return + } + + const query = await searchParams + const page = readPage(query.page) + const type = readPostType(query.type) + const view = await findProjectView(project, scope) + + if (!view) { + notFound() + } + + const [stats, months, list, typeCounts, highest] = await Promise.all([ + loadArchiveStats({ scope, projectSlug: project, recentDays }), + listArchiveMonths({ scope, projectSlug: project }), + listPostsForArchive({ scope, projectSlug: project, year, type, page, perPage }), + listPostTypeCounts({ scope, projectSlug: project, year }), + loadHighestNumber({ scope, projectSlug: project }), + ]) + + const covers = await loadPostCovers(list.items.slice(0, coverCount).map(item => item.id)) + + return ( + + ) +} diff --git a/src/app/(site)/[project]/page.tsx b/src/app/(site)/[project]/page.tsx new file mode 100644 index 0000000..a5a62b2 --- /dev/null +++ b/src/app/(site)/[project]/page.tsx @@ -0,0 +1,76 @@ +import type { Metadata } from 'next' +import { notFound } from 'next/navigation' +import { getTranslations } from 'next-intl/server' +import { ProjectArchive } from '~/components/archive/ProjectArchive' +import { + listArchiveMonths, + listPostTypeCounts, + listPostsForArchive, + loadArchiveStats, + loadHighestNumber, + loadPostCovers, +} from '~/data/repositories/archive' +import { findProjectView } from '~/lib/project-view' +import { perPage, readPage, readPostType, recentDays, type SearchParams } from '~/lib/query' +import type { ViewerScope } from '~/domain/types' + +const scope: ViewerScope = 'internal' + +const coverCount = 3 + +type Props = { + params: Promise<{ project: string }> + searchParams: Promise +} + +export async function generateMetadata({ params }: Props): Promise { + const { project } = await params + const app = await getTranslations('app') + const view = await findProjectView(project, scope) + + if (!view) { + const t = await getTranslations('notFound') + return { title: `${app('name')} - ${t('title')}` } + } + + return { + title: `${view.project.name} - ${app('name')}`, + description: view.project.description ?? undefined, + } +} + +export default async function ProjectPage({ params, searchParams }: Props) { + const { project } = await params + const query = await searchParams + const page = readPage(query.page) + const type = readPostType(query.type) + const view = await findProjectView(project, scope) + + if (!view) { + notFound() + } + + const [stats, months, list, typeCounts, highest] = await Promise.all([ + loadArchiveStats({ scope, projectSlug: project, recentDays }), + listArchiveMonths({ scope, projectSlug: project }), + listPostsForArchive({ scope, projectSlug: project, type, page, perPage }), + listPostTypeCounts({ scope, projectSlug: project }), + loadHighestNumber({ scope, projectSlug: project }), + ]) + + const covers = await loadPostCovers(list.items.slice(0, coverCount).map(item => item.id)) + + return ( + + ) +} diff --git a/src/app/(site)/layout.tsx b/src/app/(site)/layout.tsx new file mode 100644 index 0000000..1a145f6 --- /dev/null +++ b/src/app/(site)/layout.tsx @@ -0,0 +1,26 @@ +import type { ReactNode } from 'react' +import { AppShell } from '~/components/layout/AppShell' +import { SiteFooter } from '~/components/layout/SiteFooter' +import { SiteNav } from '~/components/layout/SiteNav' +import { Wordmark } from '~/components/layout/Wordmark' +import { loadHighestNumber } from '~/data/repositories/archive' +import { requireSession } from '~/lib/auth-guards' +import { currentPath } from '~/lib/request-path' +import type { ViewerScope } from '~/domain/types' + +const scope: ViewerScope = 'internal' + +export default async function SiteLayout({ children }: { children: ReactNode }) { + const viewer = await requireSession(await currentPath()) + const number = await loadHighestNumber({ scope }) + + return ( + } + mark={} + > + {children} + + + ) +} diff --git a/src/app/(site)/page.tsx b/src/app/(site)/page.tsx new file mode 100644 index 0000000..43b0b01 --- /dev/null +++ b/src/app/(site)/page.tsx @@ -0,0 +1,130 @@ +import type { Metadata } from 'next' +import Link from 'next/link' +import { getTranslations } from 'next-intl/server' +import { ArchiveMonths } from '~/components/archive/ArchiveMonths' +import { EmptyNotice } from '~/components/archive/EmptyNotice' +import { EntryList } from '~/components/archive/EntryList' +import { FeatureEntry } from '~/components/archive/FeatureEntry' +import { FilterBar } from '~/components/archive/FilterBar' +import { Pagination } from '~/components/archive/Pagination' +import { SideEntries } from '~/components/archive/SideEntries' +import { TypeFilter } from '~/components/archive/TypeFilter' +import { ViewHeader } from '~/components/archive/ViewHeader' +import { Wrap } from '~/components/layout/Wrap' +import { + listArchiveMonths, + listPostTypeCounts, + listPostsForArchive, + loadArchiveStats, + loadPostCovers, +} from '~/data/repositories/archive' +import { perPage, readMonth, readPage, readPostType, readYear, recentDays, type SearchParams } from '~/lib/query' +import { adminPath, overviewPath } from '~/lib/routes' +import type { ViewerScope } from '~/domain/types' + +const scope: ViewerScope = 'internal' + +const sideCount = 3 + +const sideThreshold = 4 + +export async function generateMetadata(): Promise { + const app = await getTranslations('app') + const t = await getTranslations('overview') + + return { + title: `${app('name')} - ${t('title')}`, + description: t('intro'), + } +} + +export default async function OverviewPage({ searchParams }: { searchParams: Promise }) { + const params = await searchParams + const page = readPage(params.page) + const year = readYear(params.year) + const month = readMonth(params.month) + const type = readPostType(params.type) + + const t = await getTranslations('overview') + const archive = await getTranslations('archive') + const entry = await getTranslations('entry') + + const [stats, months, list, typeCounts] = await Promise.all([ + loadArchiveStats({ scope, recentDays }), + listArchiveMonths({ scope }), + listPostsForArchive({ scope, year, month, type, page, perPage }), + listPostTypeCounts({ scope, year, month }), + ]) + + const period = year !== undefined || month !== undefined + const narrowed = period || type !== undefined + const beyond = list.total > 0 && list.items.length === 0 + const emptyTitle = beyond ? t('emptyPageTitle') : type && list.total === 0 ? t('emptyTypeTitle') : period ? t('emptyFilteredTitle') : t('emptyTitle') + const emptyText = beyond ? t('emptyPage') : type && list.total === 0 ? t('emptyType') : period ? t('emptyFiltered') : t('empty') + const lead = page === 1 && !narrowed ? list.items[0] : undefined + const side = lead && list.items.length >= sideThreshold ? list.items.slice(1, 1 + sideCount) : [] + const rows = lead ? list.items.slice(1 + side.length) : list.items + const updated = page === 1 ? list.items[0]?.publishAt ?? null : stats.to ?? null + const typeTotal = typeCounts.reduce((sum, count) => sum + count.postCount, 0) + const covers = await loadPostCovers(lead ? [lead, ...side].map(item => item.id) : []) + + return ( +
+ + + overviewPath({ year, month, type: target })} + /> + + + + + {list.items.length === 0 ? ( + + {beyond ? archive('firstPage') : narrowed ? archive('all') : t('toAdmin')} + + } + > + {emptyText} + + ) : ( +
+ {lead ? ( +
+ + +
+ ) : null} + + + + overviewPath({ page: target, year, month, type })} + /> +
+ )} + +
+ overviewPath({ year: item.year, month: item.month, type })} + /> +
+
+
+ ) +} diff --git a/src/app/(site)/search/page.tsx b/src/app/(site)/search/page.tsx new file mode 100644 index 0000000..97eb86e --- /dev/null +++ b/src/app/(site)/search/page.tsx @@ -0,0 +1,125 @@ +import type { Metadata } from 'next' +import Form from 'next/form' +import Link from 'next/link' +import { getTranslations } from 'next-intl/server' +import { TbSearch } from 'react-icons/tb' +import { EmptyNotice } from '~/components/archive/EmptyNotice' +import { EntryList } from '~/components/archive/EntryList' +import { Pagination } from '~/components/archive/Pagination' +import { ViewHeader } from '~/components/archive/ViewHeader' +import { Wrap } from '~/components/layout/Wrap' +import { listPostsForArchive, listRecentPosts } from '~/data/repositories/archive' +import { perPage, readPage, readText, type SearchParams } from '~/lib/query' +import { archivePath, overviewPath, searchPath } from '~/lib/routes' +import type { ViewerScope } from '~/domain/types' + +const scope: ViewerScope = 'internal' + +const suggestionCount = 5 + +export async function generateMetadata({ searchParams }: { searchParams: Promise }): Promise { + const app = await getTranslations('app') + const t = await getTranslations('search') + const query = readText((await searchParams).q) + + return { title: query ? `${t('title')}: ${query} - ${app('name')}` : `${t('title')} - ${app('name')}` } +} + +export default async function SearchPage({ searchParams }: { searchParams: Promise }) { + const params = await searchParams + const query = readText(params.q) + const page = readPage(params.page) + + const t = await getTranslations('search') + const archive = await getTranslations('archive') + const nav = await getTranslations('nav') + const overview = await getTranslations('overview') + + const list = query + ? await listPostsForArchive({ scope, query, page, perPage }) + : { items: [], total: 0 } + + const suggestions = list.total === 0 ? await listRecentPosts({ scope, limit: suggestionCount }) : [] + + return ( +
+ + +
+ + + + +
+
+ +
+ {list.total > 0 && list.items.length === 0 ? ( + + {archive('firstPage')} + + } + > + {overview('emptyPage')} + + ) : list.total > 0 ? ( + <> + + searchPath({ query, page: target })} + /> + + ) : ( + <> + + + {archive('all')} + + + {nav('archive')} + + + } + > + {query === undefined ? t('prompt') : t('empty', { query })} + + + + + )} +
+
+
+ ) +} diff --git a/src/app/admin/api/page.tsx b/src/app/admin/api/page.tsx new file mode 100644 index 0000000..84268f1 --- /dev/null +++ b/src/app/admin/api/page.tsx @@ -0,0 +1,327 @@ +import type { Metadata } from 'next' +import Link from 'next/link' +import { TbBook, TbDownload } from 'react-icons/tb' +import { AdminHeading } from '~/components/admin/AdminHeading' +import { ApiEndpoint } from '~/components/admin/ApiEndpoint' +import { cellClass, headCellClass } from '~/components/admin/styles' +import { SectionLabel } from '~/components/ui/SectionLabel' +import { Scroller } from '~/components/ui/Scroller' +import { blockTypes } from '~/domain/blocks' +import { listActivePostTypes } from '~/data/repositories/post-types' +import { requireAdminArea } from '~/lib/auth-guards' +import { adminClientsPath } from '~/lib/admin-routes' + +export const metadata: Metadata = { title: 'API - Logbuch' } + +const blockFields: Record = { + text: 'text', + image: 'mediaId', + gallery: 'mediaIds', + before_after: 'beforeMediaId, afterMediaId', + video: 'url, title', + quote: 'text, source', + code: 'code, language', + link: 'url, title, description', + callout: 'text, tone', +} + +const audiences = [ + { key: 'internal', text: 'Nur intern. Umbauten, Zwischenstände, alles über Kunden.' }, + { key: 'customer', text: 'Für Kunden des Produkts. Sichtbar für interne und Kunden-Zugänge.' }, + { key: 'public', text: 'Öffentlich. Sichtbar für jeden Zugang.' }, +] + +export default async function AdminApiPage() { + await requireAdminArea() + + const types = await listActivePostTypes() + + return ( +
+ + +
+ Wofür Logbuch da ist +
+

+ Logbuch hält fest, was in den Produkten passiert ist. Alle zwei Wochen entsteht pro Projekt ein + Eintrag über das, was fertig geworden ist. +

+

+ Es ist kein Änderungsprotokoll für Entwickler und keine Pressemitteilung. Es ist für Kollegen und + später Kunden, die wissen wollen, was sich geändert hat, ohne Tickets oder Code zu lesen. Wer über + die Schnittstelle schreibt, hält sich an den Leitfaden, abrufbar unter + {' '} + GET /api/v1/style-guide. +

+
+
+ +
+ Zum Mitnehmen +
+

+ Die Beschreibung im OpenAPI-Format lässt sich in Postman, Bruno, Insomnia und ähnliche Werkzeuge + einlesen, und ein KI-Assistent kann sie direkt verwenden. +

+ +

+ Beide Adressen verlangen ein Token. In Postman oder Bruno trägst du es unter Authorization als + Bearer ein. +

+
+
+ +
+ Anmeldung +
+

+ Jede Anfrage trägt ein Token im Kopf: + {' '} + Authorization: Bearer <token> +

+

+ Zugänge legst du unter{' '} + Zugänge an. Das Token wird dort einmal im Klartext gezeigt und + danach nie wieder, gespeichert ist nur ein Hash. +

+
+ + + + + + + + + + + + + + + + + + + + + + + +
EigenschaftBedeutung
Modusread liest, write schreibt zusätzlich
Zielgruppe + Bestimmt, welche Einträge der Zugang sieht und wie offen er selbst schreiben darf +
ProjektbindungIst ein Projekt gesetzt, gilt der Zugang ausschließlich dort
+
+ +

+ Ein Zugang mit Schreibrecht kann niemals veröffentlichen. Alles landet als Entwurf, ein Mensch gibt + frei. +

+
+ +
+ Lesen + + + + + + + + + + + + "]}\' \\\n http://localhost:4700/api/v1/read'} + /> +
+ +
+ Schreiben + + + + /blocks'} + /> + + '} + /> + + '} + /> + + '} + /> + + " \\\n -F "alt=Spaltenmenü mit den neuen Einträgen" \\\n http://localhost:4700/api/v1/media'} + /> +
+ +
+ Beitragsarten + + + + + + + + + + {types.map(type => ( + + + + + ))} + +
SchlüsselBezeichnung
{type.key}{type.labelDe}
+
+
+ +
+ Zielgruppen + + + + + + + + + + {audiences.map(audience => ( + + + + + ))} + +
SchlüsselBedeutung
{audience.key}{audience.text}
+
+

+ Ein Zugang darf keine Zielgruppe setzen, die offener ist als seine eigene. Ein Zugang mit customer + kann internal und customer schreiben, public nicht. +

+
+ +
+ Blocktypen + + + + + + + + + + {blockTypes.map(type => ( + + + + + ))} + +
TypFelder in data
{type}{blockFields[type] ?? '-'}
+
+
+ +
+ Fehler +

+ Fehler kommen einheitlich als Problem-JSON mit type, title, status und detail. 401 fehlendes oder + widerrufenes Token, 403 fehlendes Recht, 404 nicht vorhanden oder nicht sichtbar, 409 bereits + veröffentlicht, 422 unbekannte Beitragsart, 400 fehlerhafte Eingabe. +

+
+
+ ) +} diff --git a/src/app/admin/brands/[id]/page.tsx b/src/app/admin/brands/[id]/page.tsx new file mode 100644 index 0000000..f74bd1e --- /dev/null +++ b/src/app/admin/brands/[id]/page.tsx @@ -0,0 +1,45 @@ +import type { Metadata } from 'next' +import { notFound } from 'next/navigation' +import { getTranslations } from 'next-intl/server' +import { AdminHeading } from '~/components/admin/AdminHeading' +import { BrandForm } from '~/components/admin/BrandForm' +import { findBrandById } from '~/data/repositories/projects' +import { isUuid } from '~/lib/admin-forms' +import { requireAdmin } from '~/lib/auth-guards' + +export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise { + const { id } = await params + const app = await getTranslations('app') + const t = await getTranslations('admin.brands') + const brand = isUuid(id) ? await findBrandById(id) : undefined + + return { title: `${brand?.name ?? t('title')} - ${app('name')}` } +} + +export default async function EditBrandPage({ params }: { params: Promise<{ id: string }> }) { + await requireAdmin() + + const { id } = await params + const brand = isUuid(id) ? await findBrandById(id) : undefined + + if (!brand) { + notFound() + } + + const t = await getTranslations('admin.brands') + + return ( +
+ + +
+ ) +} diff --git a/src/app/admin/brands/new/page.tsx b/src/app/admin/brands/new/page.tsx new file mode 100644 index 0000000..a0d7c9c --- /dev/null +++ b/src/app/admin/brands/new/page.tsx @@ -0,0 +1,25 @@ +import type { Metadata } from 'next' +import { getTranslations } from 'next-intl/server' +import { AdminHeading } from '~/components/admin/AdminHeading' +import { BrandForm } from '~/components/admin/BrandForm' +import { requireAdmin } from '~/lib/auth-guards' + +export async function generateMetadata(): Promise { + const app = await getTranslations('app') + const t = await getTranslations('admin.brands') + + return { title: `${t('createTitle')} - ${app('name')}` } +} + +export default async function NewBrandPage() { + await requireAdmin() + + const t = await getTranslations('admin.brands') + + return ( +
+ + +
+ ) +} diff --git a/src/app/admin/brands/page.tsx b/src/app/admin/brands/page.tsx new file mode 100644 index 0000000..e5d81ba --- /dev/null +++ b/src/app/admin/brands/page.tsx @@ -0,0 +1,104 @@ +import type { Metadata } from 'next' +import Link from 'next/link' +import { getTranslations } from 'next-intl/server' +import { TbPlus } from 'react-icons/tb' +import { AdminHeading } from '~/components/admin/AdminHeading' +import { AdminNotice } from '~/components/admin/AdminNotice' +import { cellClass, ghostButtonClass, headCellClass, quietLinkClass } from '~/components/admin/styles' +import { projectStyle, projectSurface } from '~/components/ui/Plate' +import { Scroller } from '~/components/ui/Scroller' +import { countProjectsPerBrand, listBrands } from '~/data/repositories/projects' +import { adminBrandPath, adminNewBrandPath } from '~/lib/admin-routes' +import { requireAdmin } from '~/lib/auth-guards' + +export async function generateMetadata(): Promise { + const app = await getTranslations('app') + const t = await getTranslations('admin.brands') + + return { title: `${t('title')} - ${app('name')}` } +} + +export default async function AdminBrandsPage() { + await requireAdmin() + + const t = await getTranslations('admin.brands') + const [brands, counts] = await Promise.all([listBrands(), countProjectsPerBrand()]) + + return ( +
+ +
+ ) +} diff --git a/src/app/admin/clients/page.tsx b/src/app/admin/clients/page.tsx new file mode 100644 index 0000000..0e74c21 --- /dev/null +++ b/src/app/admin/clients/page.tsx @@ -0,0 +1,95 @@ +import type { Metadata } from 'next' +import { getTranslations } from 'next-intl/server' +import { AdminHeading } from '~/components/admin/AdminHeading' +import { AdminNotice } from '~/components/admin/AdminNotice' +import { ClientForm } from '~/components/admin/ClientForm' +import { ClientRevokeButton } from '~/components/admin/ClientRevokeButton' +import { cellClass, headCellClass } from '~/components/admin/styles' +import { EntryDate } from '~/components/ui/EntryDate' +import { SectionLabel } from '~/components/ui/SectionLabel' +import { Scroller } from '~/components/ui/Scroller' +import { listClients } from '~/data/repositories/clients' +import { listAllProjects } from '~/data/repositories/projects' +import { requireAdmin } from '~/lib/auth-guards' + +export async function generateMetadata(): Promise { + const app = await getTranslations('app') + const t = await getTranslations('admin.clients') + + return { title: `${t('title')} - ${app('name')}` } +} + +export default async function AdminClientsPage() { + await requireAdmin() + + const t = await getTranslations('admin.clients') + const [clients, projects] = await Promise.all([listClients(), listAllProjects()]) + const projectNames = new Map(projects.map(project => [project.id, project.name])) + + return ( +
+ + +
+ {t('meta', { count: clients.length })}}>{t('listTitle')} + + {clients.length === 0 ? ( + {t('empty')} + ) : ( + + + + + + + + + + + + + + + {clients.map(client => ( + + + + + + + + + + ))} + +
{t('columns.name')}{t('columns.mode')}{t('columns.scope')}{t('columns.project')}{t('columns.lastUsed')}{t('columns.state')} + {t('columns.action')} +
+ {client.name} + {t(`modes.${client.mode}`)}{t(`scopes.${client.scope}`)} + {client.projectId === null + ? t('allProjects') + : projectNames.get(client.projectId) ?? t('unknownProject')} + + {client.lastUsedAt ? : t('never')} + + {client.revokedAt ? ( + {t('revoked')} + ) : ( + {t('activeState')} + )} + + {client.revokedAt ? null : } +
+
+ )} +
+ +
+ {t('createTitle')} +

{t('createIntro')}

+ ({ id: project.id, name: project.name }))} /> +
+
+ ) +} diff --git a/src/app/admin/entries/[id]/page.tsx b/src/app/admin/entries/[id]/page.tsx new file mode 100644 index 0000000..d54f13a --- /dev/null +++ b/src/app/admin/entries/[id]/page.tsx @@ -0,0 +1,80 @@ +import type { Metadata } from 'next' +import { notFound } from 'next/navigation' +import { getFormatter, getLocale, getTranslations } from 'next-intl/server' +import { EntryEditor } from '~/components/admin/EntryEditor' +import { findEntry, listEntryBlocks } from '~/data/repositories/entries' +import { listMediaForProject } from '~/data/repositories/media' +import { listActivePostTypes } from '~/data/repositories/post-types' +import { parseBlocks } from '~/domain/blocks' +import { projectCode } from '~/domain/project-code' +import { adminEntryPath } from '~/lib/admin-routes' +import { requireProjectAccess } from '~/lib/auth-guards' +import { toEntryMediaList } from '~/lib/entry-media' +import { dateTimeFormat } from '~/lib/dates' +import { supportedMimes } from '~/lib/media-images' +import { toTypeOption, toTypeOptions, withCurrentType } from '~/lib/post-types' +import type { Locale } from '~/i18n/config' + +type Params = Promise<{ id: string }> + +export async function generateMetadata({ params }: { params: Params }): Promise { + const app = await getTranslations('app') + const t = await getTranslations('admin.entries') + const { id } = await params + const entry = await findEntry(id) + + return { title: `${entry?.title ?? t('editTitle')} - ${app('name')}` } +} + +export default async function EditEntryPage({ params }: { params: Params }) { + const { id } = await params + const entry = await findEntry(id) + + if (!entry) { + notFound() + } + + await requireProjectAccess(entry.projectId, adminEntryPath(entry.id)) + + const format = await getFormatter() + const locale = await getLocale() as Locale + const [rows, media, types] = await Promise.all([ + listEntryBlocks(entry.id), + listMediaForProject(entry.projectId), + listActivePostTypes(), + ]) + const parsed = parseBlocks(rows.map(row => ({ type: row.type, data: row.data }))) + + return ( +
+ +
+ ) +} diff --git a/src/app/admin/entries/new/page.tsx b/src/app/admin/entries/new/page.tsx new file mode 100644 index 0000000..d4e0102 --- /dev/null +++ b/src/app/admin/entries/new/page.tsx @@ -0,0 +1,85 @@ +import type { Metadata } from 'next' +import Link from 'next/link' +import { cookies } from 'next/headers' +import { getLocale, getTranslations } from 'next-intl/server' +import { AdminHeading } from '~/components/admin/AdminHeading' +import { AdminNotice } from '~/components/admin/AdminNotice' +import { EntryEditor } from '~/components/admin/EntryEditor' +import { quietLinkClass } from '~/components/admin/styles' +import { listMediaForProject } from '~/data/repositories/media' +import { listActivePostTypes } from '~/data/repositories/post-types' +import { listAllProjects } from '~/data/repositories/projects' +import { listProjectsForUser } from '~/data/repositories/user-roles' +import { projectCode } from '~/domain/project-code' +import { adminNewProjectPath, lastProjectCookie } from '~/lib/admin-routes' +import { isAdmin } from '~/lib/auth-access' +import { requireAdminArea } from '~/lib/auth-guards' +import { toEntryMediaList } from '~/lib/entry-media' +import { supportedMimes } from '~/lib/media-images' +import { toTypeOptions } from '~/lib/post-types' +import type { Locale } from '~/i18n/config' + +type SearchParams = Promise> + +export async function generateMetadata(): Promise { + const app = await getTranslations('app') + const t = await getTranslations('admin.entries') + + return { title: `${t('createTitle')} - ${app('name')}` } +} + +export default async function NewEntryPage({ searchParams }: { searchParams: SearchParams }) { + const viewer = await requireAdminArea() + const t = await getTranslations('admin.entries') + const params = await searchParams + const manages = isAdmin(viewer) + const projects = manages ? await listAllProjects() : await listProjectsForUser(viewer.id) + const wanted = typeof params.project === 'string' ? params.project : '' + const store = await cookies() + const remembered = store.get(lastProjectCookie)?.value ?? '' + + const chosen = projects.find(project => project.slug === wanted) + ?? projects.find(project => project.id === remembered) + ?? projects[0] + + if (!chosen) { + return ( +
+ + + {t('toProjects')} + + ) : null + } + > + {manages ? t('noProjectsForAdmin') : t('noProjects')} + +
+ ) + } + + const locale = await getLocale() as Locale + const [media, types] = await Promise.all([listMediaForProject(chosen.id), listActivePostTypes()]) + + return ( +
+ ({ + id: project.id, + slug: project.slug, + name: project.name, + code: projectCode(project), + color: project.color, + }))} + types={toTypeOptions(types, locale)} + media={toEntryMediaList(media)} + accept={supportedMimes().join(',')} + defaultProjectId={chosen.id} + /> +
+ ) +} diff --git a/src/app/admin/entries/page.tsx b/src/app/admin/entries/page.tsx new file mode 100644 index 0000000..ba47294 --- /dev/null +++ b/src/app/admin/entries/page.tsx @@ -0,0 +1,228 @@ +import type { Metadata, Route } from 'next' +import Link from 'next/link' +import { getLocale, getTranslations } from 'next-intl/server' +import { TbPlus } from 'react-icons/tb' +import { AdminHeading } from '~/components/admin/AdminHeading' +import { AdminNotice } from '~/components/admin/AdminNotice' +import { EntryFilters } from '~/components/admin/EntryFilters' +import { EntryStatusChip } from '~/components/admin/EntryStatusChip' +import { cellClass, ghostButtonClass, headCellClass, quietLinkClass } from '~/components/admin/styles' +import { EntryDate } from '~/components/ui/EntryDate' +import { entryMark, projectStyle, projectSurface } from '~/components/ui/Plate' +import { Scroller } from '~/components/ui/Scroller' +import { SectionLabel } from '~/components/ui/SectionLabel' +import { listEntries } from '~/data/repositories/entries' +import { listAllProjects } from '~/data/repositories/projects' +import { listPostTypes } from '~/data/repositories/post-types' +import { listProjectsForUser } from '~/data/repositories/user-roles' +import { isPostStatus } from '~/domain/post-status' +import { isPostTypeKey, postTypeLabel } from '~/domain/post-type' +import { adminEntriesPath, adminNewEntryPath, adminEntryPath } from '~/lib/admin-routes' +import { isAdmin } from '~/lib/auth-access' +import { requireAdminArea } from '~/lib/auth-guards' +import { toTypeOptions } from '~/lib/post-types' +import type { Locale } from '~/i18n/config' + +const perPage = 25 + +type SearchParams = Promise> + +function single(value: string | string[] | undefined): string { + return typeof value === 'string' ? value.trim() : '' +} + +export async function generateMetadata(): Promise { + const app = await getTranslations('app') + const t = await getTranslations('admin.entries') + + return { title: `${t('title')} - ${app('name')}` } +} + +export default async function AdminEntriesPage({ searchParams }: { searchParams: SearchParams }) { + const viewer = await requireAdminArea() + const t = await getTranslations('admin.entries') + const params = await searchParams + const manages = isAdmin(viewer) + const projects = manages ? await listAllProjects() : await listProjectsForUser(viewer.id) + const locale = await getLocale() as Locale + const types = await listPostTypes() + + const projectSlug = single(params.project) + const statusValue = single(params.status) + const typeValue = single(params.type) + const search = single(params.q) + const page = Math.max(1, Number(single(params.page)) || 1) + const selected = projects.find(project => project.slug === projectSlug) + const filtered = projectSlug !== '' || statusValue !== '' || typeValue !== '' || search !== '' + + const result = projects.length === 0 + ? { items: [], total: 0 } + : await listEntries({ + projectIds: manages ? undefined : projects.map(project => project.id), + projectId: selected?.id, + status: isPostStatus(statusValue) ? statusValue : undefined, + type: isPostTypeKey(typeValue) ? typeValue : undefined, + search, + page, + perPage, + }) + + const pages = Math.max(1, Math.ceil(result.total / perPage)) + const query = new URLSearchParams() + + if (projectSlug !== '') { + query.set('project', projectSlug) + } + + if (statusValue !== '') { + query.set('status', statusValue) + } + + if (typeValue !== '') { + query.set('type', typeValue) + } + + if (search !== '') { + query.set('q', search) + } + + function pagePath(target: number): Route { + const next = new URLSearchParams(query) + + if (target > 1) { + next.set('page', String(target)) + } + + const suffix = next.toString() + + return (suffix === '' ? adminEntriesPath : `${adminEntriesPath}?${suffix}`) as Route + } + + return ( +
+ 0 ? ( + +
+ ) +} diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx new file mode 100644 index 0000000..b5d19e5 --- /dev/null +++ b/src/app/admin/layout.tsx @@ -0,0 +1,36 @@ +import type { Metadata } from 'next' +import type { ReactNode } from 'react' +import { getTranslations } from 'next-intl/server' +import { AppShell } from '~/components/layout/AppShell' +import { AdminNav } from '~/components/layout/AdminNav' +import { Wrap } from '~/components/layout/Wrap' +import { Wordmark } from '~/components/layout/Wordmark' +import { loadHighestNumber } from '~/data/repositories/archive' +import { requireSession } from '~/lib/auth-guards' +import { currentPath } from '~/lib/request-path' +import type { ViewerScope } from '~/domain/types' + +const scope: ViewerScope = 'internal' + +export async function generateMetadata(): Promise { + const app = await getTranslations('app') + const t = await getTranslations('admin') + + return { title: `${t('title')} - ${app('name')}` } +} + +export default async function AdminLayout({ children }: { children: ReactNode }) { + const viewer = await requireSession(await currentPath()) + const number = await loadHighestNumber({ scope }) + + return ( + } + mark={} + > +
+ {children} +
+
+ ) +} diff --git a/src/app/admin/media/[project]/page.tsx b/src/app/admin/media/[project]/page.tsx new file mode 100644 index 0000000..7f83b03 --- /dev/null +++ b/src/app/admin/media/[project]/page.tsx @@ -0,0 +1,61 @@ +import Link from 'next/link' +import { notFound } from 'next/navigation' +import { getTranslations } from 'next-intl/server' +import { TbArrowNarrowLeft } from 'react-icons/tb' +import { AdminHeading } from '~/components/admin/AdminHeading' +import { AdminNotice } from '~/components/admin/AdminNotice' +import { MediaCard } from '~/components/admin/MediaCard' +import { MediaUploadForm } from '~/components/admin/MediaUploadForm' +import { ghostButtonClass } from '~/components/admin/styles' +import { SectionLabel } from '~/components/ui/SectionLabel' +import { listMediaForProject } from '~/data/repositories/media' +import { findProjectBySlug } from '~/data/repositories/projects' +import { requireProjectAccess } from '~/lib/auth-guards' +import { adminMediaPath, adminMediaProjectPath } from '~/lib/auth-routes' +import { supportedMimes } from '~/lib/media-images' + +export default async function AdminProjectMediaPage({ params }: { params: Promise<{ project: string }> }) { + const { project: slug } = await params + const project = await findProjectBySlug(slug) + + if (!project) { + notFound() + } + + await requireProjectAccess(project.id, adminMediaProjectPath(project.slug)) + + const t = await getTranslations('media') + const items = await listMediaForProject(project.id) + + return ( +
+ +
+ ) +} diff --git a/src/app/admin/media/page.tsx b/src/app/admin/media/page.tsx new file mode 100644 index 0000000..4ae2d06 --- /dev/null +++ b/src/app/admin/media/page.tsx @@ -0,0 +1,51 @@ +import Link from 'next/link' +import { getTranslations } from 'next-intl/server' +import { TbArrowNarrowRight } from 'react-icons/tb' +import { AdminHeading } from '~/components/admin/AdminHeading' +import { AdminNotice } from '~/components/admin/AdminNotice' +import { metaClass } from '~/components/admin/styles' +import { projectStyle, projectSurface } from '~/components/ui/Plate' +import { countMediaForProject } from '~/data/repositories/media' +import { listAllProjects } from '~/data/repositories/projects' +import { listProjectsForUser } from '~/data/repositories/user-roles' +import { isAdmin } from '~/lib/auth-access' +import { requireAdminArea } from '~/lib/auth-guards' +import { adminMediaPath, adminMediaProjectPath } from '~/lib/auth-routes' + +export default async function AdminMediaPage() { + const viewer = await requireAdminArea(adminMediaPath) + const t = await getTranslations('media') + const projects = isAdmin(viewer) ? await listAllProjects() : await listProjectsForUser(viewer.id) + const counts = await Promise.all(projects.map(project => countMediaForProject(project.id))) + + return ( +
+ + + {projects.length === 0 ? ( + {t('emptyProjects')} + ) : ( +
    + {projects.map((project, index) => ( +
  • + +
  • + ))} +
+ )} +
+ ) +} diff --git a/src/app/admin/no-access/page.tsx b/src/app/admin/no-access/page.tsx new file mode 100644 index 0000000..01ccb9d --- /dev/null +++ b/src/app/admin/no-access/page.tsx @@ -0,0 +1,35 @@ +import Link from 'next/link' +import { getTranslations } from 'next-intl/server' +import { AdminNotice } from '~/components/admin/AdminNotice' +import { canOpenAdmin } from '~/lib/auth-access' +import { requireSession } from '~/lib/auth-guards' +import { adminPath } from '~/lib/auth-routes' + +export default async function AdminNoAccessPage() { + const viewer = await requireSession() + const t = await getTranslations('admin.noAccess') + const hasSomeAccess = canOpenAdmin(viewer) + + return ( +
+
+

{t('title')}

+
+ + + {t('toOverview')} + + ) : null + } + > + {hasSomeAccess ? t('forModerator') : t('forNobody')} + + +

{t('wrongAccount')}

+
+ ) +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx new file mode 100644 index 0000000..e00b426 --- /dev/null +++ b/src/app/admin/page.tsx @@ -0,0 +1,146 @@ +import Link from 'next/link' +import { getTranslations } from 'next-intl/server' +import { TbArrowNarrowRight, TbPhoto, TbPlus } from 'react-icons/tb' +import { AdminHeading } from '~/components/admin/AdminHeading' +import { AdminNotice } from '~/components/admin/AdminNotice' +import { ghostButtonClass, quietLinkClass } from '~/components/admin/styles' +import { projectStyle, projectSurface } from '~/components/ui/Plate' +import { SectionLabel } from '~/components/ui/SectionLabel' +import { listClients } from '~/data/repositories/clients' +import { listAllProjects, listBrands } from '~/data/repositories/projects' +import { listUsers } from '~/data/repositories/users' +import { listProjectsForUser } from '~/data/repositories/user-roles' +import { projectCode } from '~/domain/project-code' +import { + adminBrandsPath, + adminClientsPath, + adminNewEntryPath, + adminNewProjectPath, + adminProjectPath, + adminProjectsPath, + adminUsersPath, +} from '~/lib/admin-routes' +import { isAdmin } from '~/lib/auth-access' +import { requireAdminArea } from '~/lib/auth-guards' +import { adminMediaPath, adminMediaProjectPath } from '~/lib/auth-routes' + +export default async function AdminPage() { + const viewer = await requireAdminArea() + const t = await getTranslations('admin') + const e = await getTranslations('admin.entries') + const m = await getTranslations('media') + const manages = isAdmin(viewer) + const projects = manages ? await listAllProjects() : await listProjectsForUser(viewer.id) + const brands = manages ? await listBrands() : [] + const users = manages ? await listUsers() : [] + const clients = manages ? await listClients() : [] + + const tiles = manages + ? [ + { href: adminProjectsPath, label: t('nav.projects'), value: projects.length }, + { href: adminBrandsPath, label: t('nav.brands'), value: brands.length }, + { href: adminUsersPath, label: t('nav.users'), value: users.length }, + { + href: adminClientsPath, + label: t('nav.clients'), + value: clients.filter(client => client.revokedAt === null).length, + }, + ] + : [] + + return ( +
+ + {projects.length > 0 ? ( + +
+ ) +} diff --git a/src/app/admin/post-types/[id]/page.tsx b/src/app/admin/post-types/[id]/page.tsx new file mode 100644 index 0000000..c342898 --- /dev/null +++ b/src/app/admin/post-types/[id]/page.tsx @@ -0,0 +1,63 @@ +import type { Metadata } from 'next' +import { notFound } from 'next/navigation' +import { getLocale, getTranslations } from 'next-intl/server' +import { AdminHeading } from '~/components/admin/AdminHeading' +import { PostTypeDeleteButton } from '~/components/admin/PostTypeDeleteButton' +import { PostTypeForm } from '~/components/admin/PostTypeForm' +import { SectionLabel } from '~/components/ui/SectionLabel' +import { countPostsOfType, findPostTypeById } from '~/data/repositories/post-types' +import { postTypeLabel } from '~/domain/post-type' +import { isUuid } from '~/lib/admin-forms' +import { requireAdmin } from '~/lib/auth-guards' +import type { Locale } from '~/i18n/config' + +type Params = Promise<{ id: string }> + +export async function generateMetadata({ params }: { params: Params }): Promise { + const { id } = await params + const app = await getTranslations('app') + const t = await getTranslations('admin.postTypes') + const locale = await getLocale() as Locale + const type = isUuid(id) ? await findPostTypeById(id) : undefined + + return { title: `${type ? postTypeLabel(type, locale) : t('title')} - ${app('name')}` } +} + +export default async function EditPostTypePage({ params }: { params: Params }) { + await requireAdmin() + + const { id } = await params + const type = isUuid(id) ? await findPostTypeById(id) : undefined + + if (!type) { + notFound() + } + + const t = await getTranslations('admin.postTypes') + const locale = await getLocale() as Locale + const postCount = await countPostsOfType(type.id) + + return ( +
+ + + 0} + /> + +
+ {t('usage', { count: postCount })}}>{t('deleteTitle')} + +
+
+ ) +} diff --git a/src/app/admin/post-types/new/page.tsx b/src/app/admin/post-types/new/page.tsx new file mode 100644 index 0000000..9dee604 --- /dev/null +++ b/src/app/admin/post-types/new/page.tsx @@ -0,0 +1,25 @@ +import type { Metadata } from 'next' +import { getTranslations } from 'next-intl/server' +import { AdminHeading } from '~/components/admin/AdminHeading' +import { PostTypeForm } from '~/components/admin/PostTypeForm' +import { requireAdmin } from '~/lib/auth-guards' + +export async function generateMetadata(): Promise { + const app = await getTranslations('app') + const t = await getTranslations('admin.postTypes') + + return { title: `${t('createTitle')} - ${app('name')}` } +} + +export default async function NewPostTypePage() { + await requireAdmin() + + const t = await getTranslations('admin.postTypes') + + return ( +
+ + +
+ ) +} diff --git a/src/app/admin/post-types/page.tsx b/src/app/admin/post-types/page.tsx new file mode 100644 index 0000000..e850128 --- /dev/null +++ b/src/app/admin/post-types/page.tsx @@ -0,0 +1,111 @@ +import type { Metadata } from 'next' +import Link from 'next/link' +import { getLocale, getTranslations } from 'next-intl/server' +import { TbPlus } from 'react-icons/tb' +import { AdminHeading } from '~/components/admin/AdminHeading' +import { AdminNotice } from '~/components/admin/AdminNotice' +import { cellClass, ghostButtonClass, headCellClass, quietLinkClass } from '~/components/admin/styles' +import { projectStyle, projectSurface } from '~/components/ui/Plate' +import { Scroller } from '~/components/ui/Scroller' +import { countPostsPerPostType, listPostTypes } from '~/data/repositories/post-types' +import { postTypeLabel } from '~/domain/post-type' +import { adminNewPostTypePath, adminPostTypePath } from '~/lib/admin-routes' +import { requireAdmin } from '~/lib/auth-guards' +import type { Locale } from '~/i18n/config' + +export async function generateMetadata(): Promise { + const app = await getTranslations('app') + const t = await getTranslations('admin.postTypes') + + return { title: `${t('title')} - ${app('name')}` } +} + +export default async function AdminPostTypesPage() { + await requireAdmin() + + const t = await getTranslations('admin.postTypes') + const locale = await getLocale() as Locale + const [types, counts] = await Promise.all([listPostTypes(), countPostsPerPostType()]) + + return ( +
+ +
+ ) +} diff --git a/src/app/admin/projects/[id]/page.tsx b/src/app/admin/projects/[id]/page.tsx new file mode 100644 index 0000000..2c8fa77 --- /dev/null +++ b/src/app/admin/projects/[id]/page.tsx @@ -0,0 +1,64 @@ +import type { Metadata } from 'next' +import Link from 'next/link' +import { notFound } from 'next/navigation' +import { getTranslations } from 'next-intl/server' +import { AdminHeading } from '~/components/admin/AdminHeading' +import { ProjectForm } from '~/components/admin/ProjectForm' +import { quietLinkClass } from '~/components/admin/styles' +import { findProjectById, listBrands } from '~/data/repositories/projects' +import { isUuid } from '~/lib/admin-forms' +import { requireAdmin } from '~/lib/auth-guards' +import { projectPath } from '~/lib/routes' + +export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise { + const { id } = await params + const app = await getTranslations('app') + const t = await getTranslations('admin.projects') + const project = isUuid(id) ? await findProjectById(id) : undefined + + return { title: `${project?.name ?? t('title')} - ${app('name')}` } +} + +export default async function EditProjectPage({ params }: { params: Promise<{ id: string }> }) { + await requireAdmin() + + const { id } = await params + const project = isUuid(id) ? await findProjectById(id) : undefined + + if (!project) { + notFound() + } + + const t = await getTranslations('admin.projects') + const brands = await listBrands() + + return ( +
+ + {t('toArchive')} + + } + /> + + ({ id: brand.id, name: brand.name }))} + project={{ + id: project.id, + name: project.name, + slug: project.slug, + code: project.code ?? '', + description: project.description ?? '', + color: project.color, + brandId: project.brandId, + sort: project.sort, + isActive: project.isActive, + }} + /> +
+ ) +} diff --git a/src/app/admin/projects/new/page.tsx b/src/app/admin/projects/new/page.tsx new file mode 100644 index 0000000..bf2a4d2 --- /dev/null +++ b/src/app/admin/projects/new/page.tsx @@ -0,0 +1,45 @@ +import type { Metadata } from 'next' +import Link from 'next/link' +import { getTranslations } from 'next-intl/server' +import { AdminHeading } from '~/components/admin/AdminHeading' +import { AdminNotice } from '~/components/admin/AdminNotice' +import { ProjectForm } from '~/components/admin/ProjectForm' +import { quietLinkClass } from '~/components/admin/styles' +import { listBrands } from '~/data/repositories/projects' +import { adminNewBrandPath } from '~/lib/admin-routes' +import { requireAdmin } from '~/lib/auth-guards' + +export async function generateMetadata(): Promise { + const app = await getTranslations('app') + const t = await getTranslations('admin.projects') + + return { title: `${t('createTitle')} - ${app('name')}` } +} + +export default async function NewProjectPage() { + await requireAdmin() + + const t = await getTranslations('admin.projects') + const brands = await listBrands() + + return ( +
+ + + {brands.length === 0 ? ( + + {t('toBrands')} + + } + > + {t('noBrands')} + + ) : ( + ({ id: brand.id, name: brand.name }))} /> + )} +
+ ) +} diff --git a/src/app/admin/projects/page.tsx b/src/app/admin/projects/page.tsx new file mode 100644 index 0000000..1db2a56 --- /dev/null +++ b/src/app/admin/projects/page.tsx @@ -0,0 +1,131 @@ +import type { Metadata } from 'next' +import Link from 'next/link' +import { getTranslations } from 'next-intl/server' +import { TbPlus } from 'react-icons/tb' +import { AdminHeading } from '~/components/admin/AdminHeading' +import { AdminNotice } from '~/components/admin/AdminNotice' +import { cellClass, ghostButtonClass, headCellClass, quietLinkClass } from '~/components/admin/styles' +import { projectStyle, projectSurface } from '~/components/ui/Plate' +import { Scroller } from '~/components/ui/Scroller' +import { countPostsPerProject, listAllProjects, listBrands } from '~/data/repositories/projects' +import { projectCode } from '~/domain/project-code' +import { adminNewBrandPath, adminNewProjectPath, adminProjectPath } from '~/lib/admin-routes' +import { requireAdmin } from '~/lib/auth-guards' + +export async function generateMetadata(): Promise { + const app = await getTranslations('app') + const t = await getTranslations('admin.projects') + + return { title: `${t('title')} - ${app('name')}` } +} + +export default async function AdminProjectsPage() { + await requireAdmin() + + const t = await getTranslations('admin.projects') + const [projects, brands, counts] = await Promise.all([ + listAllProjects(), + listBrands(), + countPostsPerProject(), + ]) + const brandNames = new Map(brands.map(brand => [brand.id, brand.name])) + + return ( +
+ 0 ? ( + +
+ ) +} diff --git a/src/app/admin/users/[id]/page.tsx b/src/app/admin/users/[id]/page.tsx new file mode 100644 index 0000000..36719a1 --- /dev/null +++ b/src/app/admin/users/[id]/page.tsx @@ -0,0 +1,107 @@ +import type { Metadata } from 'next' +import Link from 'next/link' +import { notFound } from 'next/navigation' +import { getTranslations } from 'next-intl/server' +import { AdminHeading } from '~/components/admin/AdminHeading' +import { AdminNotice } from '~/components/admin/AdminNotice' +import { UserAdminToggle } from '~/components/admin/UserAdminToggle' +import { UserRoleRow } from '~/components/admin/UserRoleRow' +import { hintClass, labelClass, quietLinkClass } from '~/components/admin/styles' +import { SectionLabel } from '~/components/ui/SectionLabel' +import { listAllProjects } from '~/data/repositories/projects' +import { findUserById } from '~/data/repositories/users' +import { listAssignmentsForUser } from '~/data/repositories/user-roles' +import { projectCode } from '~/domain/project-code' +import { adminNewProjectPath, adminUsersPath } from '~/lib/admin-routes' +import { requireAdmin } from '~/lib/auth-guards' + +export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise { + const { id } = await params + const app = await getTranslations('app') + const t = await getTranslations('admin.users') + const user = await findUserById(id) + + return { title: `${user?.name ?? t('title')} - ${app('name')}` } +} + +export default async function AdminUserPage({ params }: { params: Promise<{ id: string }> }) { + const viewer = await requireAdmin() + const { id } = await params + const user = await findUserById(id) + + if (!user) { + notFound() + } + + const t = await getTranslations('admin.users') + const [projects, assignments] = await Promise.all([listAllProjects(), listAssignmentsForUser(user.id)]) + const assigned = new Set(assignments.map(assignment => assignment.projectId)) + + return ( +
+ + {t('back')} + + } + /> + +
+ {t('adminSection')} +

+ {user.isAdmin ? t('isAdmin') : t('isNotAdmin')} +

+ +
+ +
+ {t('assignedMeta', { count: assigned.size })}}> + {t('rolesSection')} + + + {user.isAdmin ? ( + {t('adminHasAll')} + ) : null} + + {projects.length === 0 ? ( + + {t('toProjects')} + + } + > + {t('noProjects')} + + ) : ( +
    + {projects.map(project => ( + + ))} +
+ )} + + {t('rolesHint')} +
+ +
+ {t('accountSection')} + {t('accountHint')} +
+
+ ) +} diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx new file mode 100644 index 0000000..2f9f08b --- /dev/null +++ b/src/app/admin/users/page.tsx @@ -0,0 +1,86 @@ +import type { Metadata } from 'next' +import Link from 'next/link' +import { getTranslations } from 'next-intl/server' +import { AdminHeading } from '~/components/admin/AdminHeading' +import { AdminNotice } from '~/components/admin/AdminNotice' +import { cellClass, headCellClass, quietLinkClass } from '~/components/admin/styles' +import { Scroller } from '~/components/ui/Scroller' +import { listUsers } from '~/data/repositories/users' +import { listAllAssignments } from '~/data/repositories/user-roles' +import { adminUserPath } from '~/lib/admin-routes' +import { requireAdmin } from '~/lib/auth-guards' + +export async function generateMetadata(): Promise { + const app = await getTranslations('app') + const t = await getTranslations('admin.users') + + return { title: `${t('title')} - ${app('name')}` } +} + +export default async function AdminUsersPage() { + const viewer = await requireAdmin() + const t = await getTranslations('admin.users') + const [users, assignments] = await Promise.all([listUsers(), listAllAssignments()]) + const counts = new Map() + + for (const assignment of assignments) { + counts.set(assignment.userId, (counts.get(assignment.userId) ?? 0) + 1) + } + + return ( +
+ + + {users.length === 0 ? ( + {t('empty')} + ) : ( + + + + + + + + + + + + + {users.map(user => ( + + + + + + + + ))} + +
{t('columns.name')}{t('columns.email')}{t('columns.role')}{t('columns.projects')} + {t('columns.edit')} +
+ + {user.name.trim() === '' ? user.email : user.name} + + {user.id === viewer.id ? ( + + {t('you')} + + ) : null} + {user.email}{user.isAdmin ? t('roles.admin') : t('roles.moderator')} + {user.isAdmin ? t('all') : counts.get(user.id) ?? 0} + + + {t('columns.edit')} + +
+
+ )} + + {t('newAccount')} +
+ ) +} diff --git a/src/app/api/auth/[...all]/route.ts b/src/app/api/auth/[...all]/route.ts new file mode 100644 index 0000000..3537787 --- /dev/null +++ b/src/app/api/auth/[...all]/route.ts @@ -0,0 +1,4 @@ +import { toNextJsHandler } from 'better-auth/next-js' +import { auth } from '~/lib/auth' + +export const { GET, POST } = toNextJsHandler(auth) diff --git a/src/app/api/v1/media/file/[...path]/route.ts b/src/app/api/v1/media/file/[...path]/route.ts new file mode 100644 index 0000000..70122c0 --- /dev/null +++ b/src/app/api/v1/media/file/[...path]/route.ts @@ -0,0 +1,29 @@ +import { problem } from '~/lib/problem' +import { getStorage, isSafeObjectPath } from '~/lib/storage' + +export const dynamic = 'force-dynamic' + +const cacheControl = 'public, max-age=31536000, immutable' + +export async function GET(_request: Request, context: { params: Promise<{ path: string[] }> }) { + const { path } = await context.params + const key = (path ?? []).join('/') + + if (!isSafeObjectPath(key)) { + return problem(400, 'invalid_path', 'Der Pfad zeigt aus dem Speicher heraus.') + } + + const object = await getStorage().read(key) + + if (!object) { + return problem(404, 'not_found') + } + + return new Response(new Uint8Array(object.body), { + headers: { + 'content-type': object.contentType, + 'content-length': String(object.byteSize), + 'cache-control': cacheControl, + }, + }) +} diff --git a/src/app/api/v1/media/route.ts b/src/app/api/v1/media/route.ts new file mode 100644 index 0000000..10d5a3b --- /dev/null +++ b/src/app/api/v1/media/route.ts @@ -0,0 +1,105 @@ +import { findProjectBySlug } from '~/data/repositories/projects' +import { resolveClient } from '~/lib/api-auth' +import { maxUploadBytes, readUploadBody, uploadMedia, type UploadFailure } from '~/lib/media-upload' +import { mediaUrl } from '~/lib/media-url' +import { problem } from '~/lib/problem' +import type { Media } from '~/data/schema' + +const failures: Record = { + file_missing: { status: 400, type: 'file_missing', detail: 'Es wurde keine Datei im Feld file mitgeschickt.' }, + file_too_large: { status: 413, type: 'file_too_large', detail: `Die Datei ist groesser als ${maxUploadBytes} Bytes.` }, + unsupported_type: { status: 415, type: 'unsupported_type', detail: 'Dieses Dateiformat wird nicht angenommen.' }, + unreadable_image: { status: 422, type: 'unreadable_image', detail: 'Die Datei laesst sich nicht als Bild lesen.' }, +} + +function body(item: Media) { + return { + id: item.id, + projectId: item.projectId, + kind: item.kind, + originalFilename: item.originalFilename, + mime: item.mime, + width: item.width, + height: item.height, + byteSize: item.byteSize, + alt: item.alt, + caption: item.caption, + url: mediaUrl(item.path), + variants: item.variants.map(variant => ({ + width: variant.width, + format: variant.format, + url: mediaUrl(variant.path), + })), + variantError: item.variantError, + createdAt: item.createdAt, + } +} + +export async function POST(request: Request) { + const client = await resolveClient(request) + + if (!client) { + return problem(401, 'unauthorized') + } + + if (client.mode !== 'write') { + return problem(403, 'forbidden', 'Dieses Token darf nur lesen.') + } + + let form: FormData + + try { + form = await request.formData() + } catch { + return problem(400, 'invalid_body', 'Der Aufruf muss multipart/form-data sein.') + } + + const file = form.get('file') + + if (!(file instanceof File) || file.size === 0) { + return problem(400, 'file_missing', 'Es wurde keine Datei im Feld file mitgeschickt.') + } + + if (file.size > maxUploadBytes) { + return problem(413, 'file_too_large', `Die Datei ist groesser als ${maxUploadBytes} Bytes.`) + } + + const requested = String(form.get('project') ?? '').trim() + let projectId = client.projectId ?? undefined + + if (projectId === undefined) { + if (requested === '') { + return problem(400, 'project_missing', 'Ohne projektgebundenes Token muss project mitgeschickt werden.') + } + + const project = await findProjectBySlug(requested) + + if (!project) { + return problem(404, 'project_unknown') + } + + projectId = project.id + } else if (requested !== '') { + const project = await findProjectBySlug(requested) + + if (!project || project.id !== projectId) { + return problem(403, 'forbidden', 'Dieses Token gehoert zu einem anderen Projekt.') + } + } + + const result = await uploadMedia({ + projectId, + filename: file.name, + body: await readUploadBody(file), + alt: String(form.get('alt') ?? '').trim() || null, + caption: String(form.get('caption') ?? '').trim() || null, + }) + + if (!result.ok) { + const failure = failures[result.reason] + + return problem(failure.status, failure.type, failure.detail) + } + + return Response.json(body(result.media), { status: 201 }) +} diff --git a/src/app/api/v1/openapi.json/route.ts b/src/app/api/v1/openapi.json/route.ts new file mode 100644 index 0000000..a69e005 --- /dev/null +++ b/src/app/api/v1/openapi.json/route.ts @@ -0,0 +1,21 @@ +import { resolveClient } from '~/lib/api-auth' +import { openApiDocument } from '~/lib/openapi' +import { problem } from '~/lib/problem' + +export async function GET(request: Request) { + const client = await resolveClient(request) + + if (!client) { + return problem(401, 'unauthorized') + } + + const url = new URL(request.url) + const document = openApiDocument(process.env.BETTER_AUTH_URL ?? url.origin) + + return new Response(JSON.stringify(document, null, '\t'), { + headers: { + 'content-type': 'application/json; charset=utf-8', + 'content-disposition': 'attachment; filename="logbuch-openapi.json"', + }, + }) +} diff --git a/src/app/api/v1/post-types/route.ts b/src/app/api/v1/post-types/route.ts new file mode 100644 index 0000000..ab3abc3 --- /dev/null +++ b/src/app/api/v1/post-types/route.ts @@ -0,0 +1,23 @@ +import { listActivePostTypes } from '~/data/repositories/post-types' +import { resolveClient } from '~/lib/api-auth' +import { problem } from '~/lib/problem' + +export async function GET(request: Request) { + const client = await resolveClient(request) + + if (!client) { + return problem(401, 'unauthorized') + } + + const items = await listActivePostTypes() + + return Response.json({ + items: items.map(item => ({ + key: item.key, + labelDe: item.labelDe, + labelEn: item.labelEn, + color: item.color, + sort: item.sort, + })), + }) +} diff --git a/src/app/api/v1/posts/[slug]/blocks/route.ts b/src/app/api/v1/posts/[slug]/blocks/route.ts new file mode 100644 index 0000000..0e30c2d --- /dev/null +++ b/src/app/api/v1/posts/[slug]/blocks/route.ts @@ -0,0 +1,33 @@ +import { z } from 'zod' +import { resolveClient } from '~/lib/api-auth' +import { apiSetBlocks } from '~/lib/api-entries' +import { entryProblem } from '~/lib/api-problem' +import { problem } from '~/lib/problem' + +const idPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu + +const bodySchema = z.object({ blocks: z.unknown() }) + +export async function PUT(request: Request, context: { params: Promise<{ slug: string }> }) { + const client = await resolveClient(request) + + if (!client) { + return problem(401, 'unauthorized') + } + + const { slug } = await context.params + + if (!idPattern.test(slug)) { + return problem(400, 'id_required', 'Zum Setzen der Bloecke wird die Kennung des Beitrags gebraucht.') + } + + const parsed = bodySchema.safeParse(await request.json().catch(() => null)) + + if (!parsed.success) { + return problem(400, 'invalid_body', 'blocks') + } + + const result = await apiSetBlocks(client, slug, parsed.data.blocks) + + return result.ok ? Response.json(result.value) : entryProblem(result.error) +} diff --git a/src/app/api/v1/posts/[slug]/route.ts b/src/app/api/v1/posts/[slug]/route.ts new file mode 100644 index 0000000..391001b --- /dev/null +++ b/src/app/api/v1/posts/[slug]/route.ts @@ -0,0 +1,91 @@ +import { z } from 'zod' +import { findPost } from '~/data/repositories/posts' +import { resolveClient } from '~/lib/api-auth' +import { apiDeleteEntry, apiReadEntry, apiUpdateEntry } from '~/lib/api-entries' +import { entryProblem } from '~/lib/api-problem' +import { problem } from '~/lib/problem' + +const idPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu + +const updateSchema = z.object({ + title: z.string().min(1).optional(), + teaser: z.string().nullish(), + type: z.string().min(1).optional(), + audience: z.enum(['internal', 'customer', 'public']).optional(), + slug: z.string().nullish(), + cover_media_id: z.string().nullish(), +}) + +export async function GET(request: Request, context: { params: Promise<{ slug: string }> }) { + const client = await resolveClient(request) + + if (!client) { + return problem(401, 'unauthorized') + } + + const { slug } = await context.params + + if (idPattern.test(slug)) { + const result = await apiReadEntry(client, slug) + + return result.ok ? Response.json(result.value) : entryProblem(result.error) + } + + const post = await findPost(slug, client.scope, client.projectId ?? undefined) + + if (!post) { + return problem(404, 'post_not_found') + } + + return Response.json(post) +} + +export async function PATCH(request: Request, context: { params: Promise<{ slug: string }> }) { + const client = await resolveClient(request) + + if (!client) { + return problem(401, 'unauthorized') + } + + const { slug } = await context.params + + if (!idPattern.test(slug)) { + return problem(400, 'id_required', 'Zum Aendern wird die Kennung des Beitrags gebraucht, nicht der Slug.') + } + + const parsed = updateSchema.safeParse(await request.json().catch(() => null)) + + if (!parsed.success) { + return problem(400, 'invalid_body', parsed.error.issues.map(issue => issue.path.join('.')).join(', ')) + } + + const body = parsed.data + const result = await apiUpdateEntry(client, slug, { + title: body.title, + teaser: body.teaser === undefined ? undefined : body.teaser, + type: body.type, + audience: body.audience, + slug: body.slug === undefined ? undefined : body.slug, + coverMediaId: body.cover_media_id === undefined ? undefined : body.cover_media_id, + }) + + return result.ok ? Response.json(result.value) : entryProblem(result.error) +} + +export async function DELETE(request: Request, context: { params: Promise<{ slug: string }> }) { + const client = await resolveClient(request) + + if (!client) { + return problem(401, 'unauthorized') + } + + const { slug } = await context.params + + if (!idPattern.test(slug)) { + return problem(400, 'id_required', 'Zum Loeschen wird die Kennung des Beitrags gebraucht, nicht der Slug.') + } + + const result = await apiDeleteEntry(client, slug) + + return result.ok ? Response.json(result.value) : entryProblem(result.error) +} diff --git a/src/app/api/v1/posts/route.ts b/src/app/api/v1/posts/route.ts new file mode 100644 index 0000000..2275091 --- /dev/null +++ b/src/app/api/v1/posts/route.ts @@ -0,0 +1,93 @@ +import { z } from 'zod' +import { listPosts } from '~/data/repositories/posts' +import { isPostTypeKey } from '~/domain/post-type' +import { apiCreateEntry } from '~/lib/api-entries' +import { entryProblem } from '~/lib/api-problem' +import { resolveClient } from '~/lib/api-auth' +import { problem } from '~/lib/problem' + +const querySchema = z.object({ + project: z.string().min(1).optional(), + since: z.coerce.date().optional(), + type: z.string().trim().toLowerCase().refine(isPostTypeKey).optional(), + tag: z.string().min(1).optional(), + page: z.coerce.number().int().min(1).default(1), + per_page: z.coerce.number().int().min(1).max(100).default(25), +}) + +export async function GET(request: Request) { + const client = await resolveClient(request) + + if (!client) { + return problem(401, 'unauthorized') + } + + const url = new URL(request.url) + const parsed = querySchema.safeParse(Object.fromEntries(url.searchParams)) + + if (!parsed.success) { + return problem(400, 'invalid_query', parsed.error.issues.map(issue => issue.path.join('.')).join(', ')) + } + + const query = parsed.data + const result = await listPosts({ + scope: client.scope, + projectSlug: query.project, + projectId: client.projectId ?? undefined, + since: query.since, + type: query.type, + tag: query.tag, + page: query.page, + perPage: query.per_page, + }) + + return Response.json({ + items: result.items, + meta: { total: result.total, page: query.page, per_page: query.per_page }, + }) +} + +const createSchema = z.object({ + project: z.string().min(1), + title: z.string().min(1), + teaser: z.string().nullish(), + type: z.string().min(1), + audience: z.enum(['internal', 'customer', 'public']).optional(), + author_name: z.string().nullish(), + slug: z.string().nullish(), + blocks: z.unknown().optional(), + idempotency_key: z.string().min(1).max(200).nullish(), +}) + +export async function POST(request: Request) { + const client = await resolveClient(request) + + if (!client) { + return problem(401, 'unauthorized') + } + + const parsed = createSchema.safeParse(await request.json().catch(() => null)) + + if (!parsed.success) { + return problem(400, 'invalid_body', parsed.error.issues.map(issue => issue.path.join('.')).join(', ')) + } + + const body = parsed.data + const result = await apiCreateEntry(client, { + project: body.project, + title: body.title, + teaser: body.teaser ?? null, + type: body.type, + audience: body.audience, + authorName: body.author_name ?? null, + slug: body.slug ?? null, + blocks: body.blocks, + idempotencyKey: body.idempotency_key ?? null, + }) + + if (!result.ok) { + return entryProblem(result.error) + } + + return Response.json(result.value, { status: 201 }) +} diff --git a/src/app/api/v1/projects/route.ts b/src/app/api/v1/projects/route.ts new file mode 100644 index 0000000..7c1558c --- /dev/null +++ b/src/app/api/v1/projects/route.ts @@ -0,0 +1,16 @@ +import { listProjects } from '~/data/repositories/projects' +import { resolveClient } from '~/lib/api-auth' +import { problem } from '~/lib/problem' + +export async function GET(request: Request) { + const client = await resolveClient(request) + + if (!client) { + return problem(401, 'unauthorized') + } + + const all = await listProjects() + const items = client.projectId ? all.filter(project => project.id === client.projectId) : all + + return Response.json({ items }) +} diff --git a/src/app/api/v1/publish-due/route.ts b/src/app/api/v1/publish-due/route.ts new file mode 100644 index 0000000..27ca786 --- /dev/null +++ b/src/app/api/v1/publish-due/route.ts @@ -0,0 +1,32 @@ +import { publishDueEntries } from '~/lib/publish-due' +import { problem } from '~/lib/problem' + +function allowed(request: Request): boolean { + const secret = process.env.CRON_SECRET?.trim() + + if (!secret) { + return false + } + + const header = request.headers.get('authorization') + + return header === `Bearer ${secret}` +} + +export async function POST(request: Request) { + if (!allowed(request)) { + return problem(401, 'unauthorized') + } + + const { published } = await publishDueEntries() + + return Response.json({ + published: published.map(entry => ({ + id: entry.id, + number: entry.number, + slug: entry.slug, + title: entry.title, + })), + meta: { total: published.length }, + }) +} diff --git a/src/app/api/v1/read/route.ts b/src/app/api/v1/read/route.ts new file mode 100644 index 0000000..bb7545e --- /dev/null +++ b/src/app/api/v1/read/route.ts @@ -0,0 +1,36 @@ +import { z } from 'zod' +import { markRead } from '~/data/repositories/reads' +import { resolveClient } from '~/lib/api-auth' +import { problem } from '~/lib/problem' + +const bodySchema = z.object({ + external_user_id: z.string().min(1), + post_ids: z.array(z.uuid()).min(1).max(200), +}) + +export async function POST(request: Request) { + const client = await resolveClient(request) + + if (!client) { + return problem(401, 'unauthorized') + } + + if (!client.projectId) { + return problem(422, 'client_without_project') + } + + const parsed = bodySchema.safeParse(await request.json().catch(() => null)) + + if (!parsed.success) { + return problem(400, 'invalid_body', parsed.error.issues.map(issue => issue.path.join('.')).join(', ')) + } + + const marked = await markRead({ + projectId: client.projectId, + externalUserId: parsed.data.external_user_id, + scope: client.scope, + postIds: parsed.data.post_ids, + }) + + return Response.json({ marked }) +} diff --git a/src/app/api/v1/style-guide/route.ts b/src/app/api/v1/style-guide/route.ts new file mode 100644 index 0000000..121567c --- /dev/null +++ b/src/app/api/v1/style-guide/route.ts @@ -0,0 +1,22 @@ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { resolveClient } from '~/lib/api-auth' +import { problem } from '~/lib/problem' + +export async function GET(request: Request) { + const client = await resolveClient(request) + + if (!client) { + return problem(401, 'unauthorized') + } + + try { + const text = await readFile(join(process.cwd(), 'docs', 'style-guide.md'), 'utf8') + + return new Response(text, { + headers: { 'content-type': 'text/markdown; charset=utf-8' }, + }) + } catch { + return problem(500, 'style_guide_missing', 'docs/style-guide.md fehlt.') + } +} diff --git a/src/app/api/v1/unread/route.ts b/src/app/api/v1/unread/route.ts new file mode 100644 index 0000000..3f04469 --- /dev/null +++ b/src/app/api/v1/unread/route.ts @@ -0,0 +1,33 @@ +import { z } from 'zod' +import { listUnread } from '~/data/repositories/reads' +import { resolveClient } from '~/lib/api-auth' +import { problem } from '~/lib/problem' + +const querySchema = z.object({ external_user_id: z.string().min(1) }) + +export async function GET(request: Request) { + const client = await resolveClient(request) + + if (!client) { + return problem(401, 'unauthorized') + } + + if (!client.projectId) { + return problem(422, 'client_without_project') + } + + const url = new URL(request.url) + const parsed = querySchema.safeParse(Object.fromEntries(url.searchParams)) + + if (!parsed.success) { + return problem(400, 'invalid_query', 'external_user_id') + } + + const items = await listUnread({ + projectId: client.projectId, + externalUserId: parsed.data.external_user_id, + scope: client.scope, + }) + + return Response.json({ count: items.length, items }) +} diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..83cf082 --- /dev/null +++ b/src/app/globals.css @@ -0,0 +1,138 @@ +@import "tailwindcss"; + +@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *)); + +@theme { + --color-paper: #f2f1ec; + --color-surface: #fbfaf6; + --color-ink: #14161a; + --color-ink-2: #4c5157; + --color-ink-3: #8a8f95; + --color-rule: #dedcd3; + --color-signal: #c43a22; + --color-link: #2b4a9b; + --shadow-lift: 0 1.5rem 3rem -1.5rem rgb(20 22 26 / 0.18); + --color-positive: #2e7d5b; + --color-caution: #b4761a; + --color-shot-1: var(--color-rule); + --color-shot-2: var(--color-ink-3); + --container-page: 63rem; + --container-read: 38rem; + --font-display: var(--font-archivo), system-ui, sans-serif; + --font-body: var(--font-source-serif), Georgia, serif; + --font-mono: var(--font-jetbrains-mono), ui-monospace, monospace; + --text-micro: 0.6875rem; + --text-small: 0.8125rem; + --text-base: 1.0625rem; + --text-lead: 1.25rem; + --text-title: 1.75rem; + --text-hero: clamp(2.25rem, 4vw, 3.25rem); + --text-plate: clamp(2rem, 5vw, 3.5rem); + --text-mark: clamp(5rem, 13vw, 10rem); + --tracking-label: 0.28em; + --tracking-mark: 0.18em; + --tracking-plate: 0.42em; + --tracking-badge: 0.24em; + --tracking-chip: 0.14em; + --aspect-cover: 21 / 9; + --aspect-shot: 16 / 10; + --breakpoint-md: 46rem; + --animate-rise: rise 0.5s both cubic-bezier(0.22, 0.61, 0.36, 1); +} + +@keyframes rise { + from { opacity: 0; transform: translateY(0.375rem); } +} + +:root[data-theme="dark"] { + --shadow-lift: 0 1.5rem 3rem -1.5rem rgb(0 0 0 / 0.65); + --color-paper: #121417; + --color-surface: #191c20; + --color-ink: #edebe4; + --color-ink-2: #a9aeb4; + --color-ink-3: #757a80; + --color-rule: #2a2e34; + --color-signal: #e4603f; + --color-link: #8aa6ee; + --color-positive: #6dbd97; + --color-caution: #d9a34e; + color-scheme: dark; +} + +:root[data-theme="light"] { + --color-paper: #f2f1ec; + --color-surface: #fbfaf6; + --color-ink: #14161a; + --color-ink-2: #4c5157; + --color-ink-3: #8a8f95; + --color-rule: #dedcd3; + --color-signal: #c43a22; + --color-link: #2b4a9b; + --color-positive: #2e7d5b; + --color-caution: #b4761a; + color-scheme: light; +} + +@layer base { + html { + background: var(--color-paper); + } + + body { + font-family: var(--font-body); + line-height: 1.6; + } + + h1, + h2, + h3 { + font-family: var(--font-display); + font-weight: 600; + letter-spacing: -0.02em; + line-height: 1.2; + } + + a { + color: var(--color-link); + text-decoration-thickness: 0.0625rem; + text-underline-offset: 0.15em; + } + + :focus-visible { + outline: 0.125rem solid var(--color-signal); + outline-offset: 0.125rem; + } + + ::selection { + background: var(--color-signal); + color: var(--color-paper); + } +} + +.plate-fill { + background: var(--plate); +} + +:root[data-theme="dark"] .plate-fill { + background: color-mix(in srgb, var(--plate) 88%, var(--color-paper)); +} + +.os-theme-logbuch { + --os-size: 0.625rem; + --os-padding-perpendicular: 0.125rem; + --os-padding-axis: 0.125rem; + --os-track-border-radius: 1rem; + --os-track-bg: transparent; + --os-track-bg-hover: transparent; + --os-track-bg-active: transparent; + --os-handle-border-radius: 1rem; + --os-handle-min-size: 2rem; + --os-handle-interactive-area-offset: 0.25rem; + --os-handle-bg: color-mix(in srgb, var(--color-ink-3) 50%, transparent); + --os-handle-bg-hover: color-mix(in srgb, var(--color-ink-3) 75%, transparent); + --os-handle-bg-active: var(--color-ink-2); +} + +.shadow-lift { + box-shadow: var(--shadow-lift); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..eec4931 --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,31 @@ +import './globals.css' +import type { Metadata } from 'next' +import type { ReactNode } from 'react' +import { NextIntlClientProvider } from 'next-intl' +import { getLocale, getTranslations } from 'next-intl/server' +import { fontVariables } from '~/lib/fonts' +import { themeInitScript } from '~/components/ui/theme' + +export async function generateMetadata(): Promise { + const t = await getTranslations('app') + + return { + title: t('name'), + description: t('tagline'), + } +} + +export default async function RootLayout({ children }: { children: ReactNode }) { + const locale = await getLocale() + + return ( + + +