diff --git a/.fleet/design-review-manual-explore-preserve.json b/.fleet/design-review-manual-explore-preserve.json new file mode 100644 index 0000000..b04d894 --- /dev/null +++ b/.fleet/design-review-manual-explore-preserve.json @@ -0,0 +1,227 @@ +{ + "$schema": "fleet.design-review.v2", + "version": 2, + "project": "live", + "target": "Preserve existing UI: manual search/custom entry and paginated categorized Explore", + "surfaceMode": "operate", + "mode": "preserve", + "register": "product", + "context": { + "product": "PRODUCT.md", + "design": "DESIGN.md" + }, + "direction": { + "productSurfaces": { + "scope": "paired", + "landing": "https://live.significanthobbies.com/ \u2014 landing-astro/src/pages/live.astro", + "app": "http://localhost:3097/bucket-list and /experiences", + "reason": "" + }, + "pairedPreview": { + "landing": "", + "app": "" + }, + "source": "existing", + "references": [], + "probes": [], + "selected": "existing-design", + "approval": "not-required", + "ownerEvidence": { + "path": "", + "quote": "" + }, + "supplied": "", + "before": ".fleet-local/manual-explore/before/list.png", + "library": { + "strategy": "upstream-first", + "primary": "Healthy project-native input, buttons and filter rows", + "sources": [ + "https://github.com/Significant-Hobbies/live/blob/main/src/components/bucket-list/quick-add-item.tsx", + "https://github.com/Significant-Hobbies/live/blob/main/src/app/experiences/experiences-client.tsx" + ], + "runtime": "none", + "customReplacement": { + "used": false, + "authorization": "not-required", + "reason": "Preserve existing project controls" + } + }, + "contract": { + "purpose": "Live helps adults live more intentionally by discovering experiences, keeping a personal list and recording completed items.", + "purposeSource": "PRODUCT.md and PROJECT_STATUS.md", + "canonicalPurpose": "Live leads with a searchable catalog of bucket-list ideas and side quests, a personal list, and optional Life Bingo boards.", + "purposeAlignment": "match", + "driftNote": "", + "audience": "Adults maintaining personal bucket lists", + "job": "Add an idea already in mind or separately explore categorized possibilities", + "thesis": "Preserve incumbent UI; single input searches as you type and saves exact personal wording; separate categorized Explore.", + "system": "Existing warm paper, dark ink, green/yellow actions; incumbent serif headings, sans-serif forms, spacing and rounded controls", + "signature": "Personal someday list and meaningful experience categories", + "risk": "Shared contribution activation requires the prepared additive migration and seed import, neither applied. Authenticated UI actions tested with mocks; SQL lifecycle tested in memory.", + "qualityBar": "Existing shell; live suggestions; private exact wording; explicit submission then review; bounded server pagination and URL navigation; keyboard use and no phone overflow" + } + }, + "evidence": { + "platform": "web", + "productContinuity": { + "status": "pass", + "reason": "Scoped preserved-design continuity; revised behavior is local only. Production release handoff remains unverified.", + "reviewer": "Codex direct rendered review", + "report": ".fleet-local/manual-explore/review.md", + "surfaces": { + "landing": ".fleet-local/manual-explore/after/landing.png", + "app": ".fleet-local/manual-explore/after/shared-typeahead-1440.png" + }, + "checks": { + "designSystem": { + "status": "pass", + "observation": "Rendered landing and local app retain existing serif landmarks, gold actions and product vocabulary; cinematic marketing and warm paper workspace reflect their existing jobs." + }, + "identity": { + "status": "pass", + "observation": "Existing Significant Hobbies name, bucket-list subject matter and incumbent imagery/colors retained." + }, + "vocabulary": { + "status": "pass", + "observation": "Explore labels discovery; landing catalog describes the same experience collection; My list remains personal storage." + }, + "productTruth": { + "status": "pass", + "observation": "Existing promises of ideas, lists and completion match local behavior; no new landing claims; proposed changes not presented as deployed." + }, + "handoff": { + "status": "pass", + "observation": "Rendered landing CTA was observed opening canonical public /experiences with catalog visible. Proposed manual and pagination paths tested separately locally; no combined revised production save-path claim." + } + } + }, + "slopScale": { + "platform": "web", + "posture": "advisory", + "reason": "", + "checkpoints": [ + { + "stage": "iteration", + "status": "scanned", + "report": ".fleet-local/manual-explore/shared-iteration.json", + "command": "node /Users/sarthak/Desktop/fleet/saas-maker/tooling/scripts/slop-score.mjs http://localhost:3097/bucket-list http://localhost:3097/experiences --json", + "findingsReview": "Incumbent sans-serif and warm-paper heuristics retained; Explore letter-avatar pattern belongs to shell. No new theme change warranted." + }, + { + "stage": "final", + "status": "scanned", + "report": ".fleet-local/manual-explore/shared-final.json", + "command": "node /Users/sarthak/Desktop/fleet/saas-maker/tooling/scripts/slop-score.mjs http://localhost:3097/bucket-list http://localhost:3097/experiences --json", + "findingsReview": "Incumbent font, cream and avatar heuristics retained; no redesign warranted. Shared typeahead and Explore rendered separately at three widths. Landing evidence and failed local scanner history retained." + } + ] + }, + "visualReview": { + "status": "pass", + "reviewer": "Codex direct rendered review", + "direction": "existing-design", + "report": ".fleet-local/manual-explore/review.md", + "checks": { + "hierarchy": { + "status": "pass", + "observation": "Existing list header and shell retained; one input presents exact-wording save and five live suggestions; Explore stays separate." + }, + "typography": { + "status": "pass", + "observation": "Existing serif landmarks and sans-serif controls; titles wrap at 390px with separate add targets." + }, + "composition": { + "status": "pass", + "observation": "Existing shell, list sections and catalog filter rows retained; result mounts bounded to 20 and manual matches to 5." + }, + "identity": { + "status": "pass", + "observation": "Preserved personal bucket-list vocabulary, warm paper, green/yellow actions and landing imagery." + }, + "interaction": { + "status": "pass", + "observation": "14 browser tests pass; six mocked-account tests cover save, explicit submission, errors and stale search races. SQL tests prove pending ideas stay private until approval. Initial hydration loading flash fixed." + }, + "responsive": { + "status": "pass", + "observation": "Both local app surfaces captured and overflow-checked at 390, 768 and 1440 pixels; controls reflow with no horizontal overflow." + } + } + }, + "screenshots": [ + { + "width": 390, + "path": ".fleet-local/manual-explore/after/shared-typeahead-390.png" + }, + { + "width": 768, + "path": ".fleet-local/manual-explore/after/shared-typeahead-768.png" + }, + { + "width": 1440, + "path": ".fleet-local/manual-explore/after/shared-typeahead-1440.png" + } + ], + "projectCheck": { + "status": "pass", + "command": "pnpm typecheck; pnpm exec biome check ; pnpm exec vitest run src/components/bucket-list/quick-add-item.test.tsx; pnpm exec playwright test --config .fleet-local/manual-explore/playwright.config.mjs" + }, + "critique": { + "score": 36, + "maximum": 40, + "dimensions": { + "hierarchy": 7, + "typography": 7, + "composition": 7, + "identity": 7, + "interaction": 4, + "responsive": 4 + } + }, + "audit": { + "score": 17, + "maximum": 20, + "dimensions": { + "purpose": 4, + "accessibility": 5, + "behavior": 3, + "responsive": 3, + "performance": 2 + } + }, + "unresolved": { + "p0": 0, + "p1": 0 + }, + "detector": { + "posture": "advisory", + "findings": [] + }, + "comprehension": { + "status": "not-applicable", + "reviewer": "", + "answers": { + "product": "", + "audience": "", + "value": "", + "mechanism": "", + "proof": "", + "nextAction": "" + }, + "purposeScore": { + "product": 0, + "audience": 0, + "value": 0, + "mechanism": 0, + "proof": 0, + "nextAction": 0, + "total": 0 + }, + "mismatches": [] + } + }, + "ownerFeedback": { + "decision": "keep", + "note": "Owner explicitly narrowed scope: bro there is no major redesign is needed. Preserve established direction; owner acceptance of finished change not inferred." + } +} diff --git a/DESIGN.md b/DESIGN.md index f505f66..3ebcbee 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -58,7 +58,7 @@ Primary pages use one dominant spatial field followed by quieter supporting pass Live leads with a verb and an immediate invitation. It must make the user want to choose an activity, not admire a planning system. Mortality belongs in History and acts as context, never as the dominant emotional register of the action surface. -The global shell exposes Catalog, My list, Weekly journal, and Life in weeks. These reuse the incumbent shell and workspaces. The bucket-list cleanup removes competing steps and navigation without replacing typography, colors, imagery, or layout. +The global shell exposes Explore, My list, Weekly journal, and Life in weeks. These reuse the incumbent shell and workspaces. The bucket-list cleanup removes competing steps and navigation without replacing typography, colors, imagery, or layout. Completion logging stays in My list. There is no Habits page or navigation link; its old URL redirects to My list. The old Daily URL redirects to the weekly journal. @@ -74,7 +74,7 @@ Controls use 12px corners and major bounded surfaces use 16px corners. Paths, co ### Navigation -The wordmark is `SH` with an accessible full name. Catalog, My list, Weekly journal, and Life in weeks are direct links in that order on desktop and mobile. Preserve the established destination colors and responsive menu. +The wordmark is `SH` with an accessible full name. Explore, My list, Weekly journal, and Life in weeks are direct links in that order on desktop and mobile. Preserve the established destination colors and responsive menu. ### Cards / Containers diff --git a/PRODUCT.md b/PRODUCT.md index 447f0f1..f1b6688 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -51,7 +51,7 @@ progress systems are limited to surfaces where proof is the explicit job. - Signed-out `/` serves the preserved Live landing with a catalog call to action. A session-bearing request enters `/bucket-list`; the application still validates the session before choosing account or local storage. -- Primary navigation is Catalog (`/experiences`), My list (`/bucket-list`), +- Primary navigation is Explore (`/experiences`), My list (`/bucket-list`), Weekly journal (`/journal`), and Life in weeks (`/life-in-weeks`). Core work does not require biography onboarding. - `/onboarding` and `/habits` redirect to My list. `/daily` redirects to the @@ -75,6 +75,13 @@ progress systems are limited to surfaces where proof is the explicit job. features instead of introducing a new shell, theme, or dashboard. - Anonymous marketing and tool HTML follows the existing Astro/Worker cache boundary. +- My list searches the shared seeded experience catalog as the person types. + Suggestions use the same catalog across users; personal wording and saved + list status remain separate from catalog data. +- Shared contributions require explicit authenticated submission and operator + review. Pending and rejected titles are not public suggestions. The shared + catalog's additive migration and seed import must be applied before enabling + database-backed contributions; existing seed discovery remains available. - The Hub has no shared database, summaries, assistant, or write actions in V1. - Existing private data must survive target deletion and reversible product changes. diff --git a/PROJECT_STATUS.md b/PROJECT_STATUS.md index e471dd9..060a927 100644 --- a/PROJECT_STATUS.md +++ b/PROJECT_STATUS.md @@ -24,6 +24,13 @@ remain available on secondary routes. ## Status and next +- Local candidate for [#40](https://github.com/Significant-Hobbies/live/issues/40): + live typeahead, server-paginated categorized Explore, shared seed catalog and + explicit reviewed contributions. The additive 0007 schema and seed artifact + are prepared; production migration, seed import and release have not run. + Existing built-in seed discovery stays available during activation. Operator + steps are in [shared catalog](docs/product/shared-catalog.md). + Live is actively usable but remains an ongoing product. Continue from observed usage and improve discovery and long-lived planning on the independent Live origin. diff --git a/docs/product/shared-catalog.md b/docs/product/shared-catalog.md new file mode 100644 index 0000000..15731f2 --- /dev/null +++ b/docs/product/shared-catalog.md @@ -0,0 +1,49 @@ +# Shared experience catalog + +Everyone searches the same approved catalog. Seed ideas come from the canonical +419-entry source corpus; personal bucket lists, completion state and notes are +separate. As-you-type search returns five results; Explore returns twenty. +Both use /api/experience-catalog and server-side pagination. + +Signed-in users can explicitly submit a title and category for review. This +action never publishes or changes their personal list. Pending/rejected ideas +and submitter IDs are never included in public responses. Approval deduplicates +normalized titles and inserts an idea into ExperienceCatalog. Rejection only +updates the submission. There is no public moderation endpoint. + +## Operator tooling + +The additive 0007 migration is prepared, not applied by this work. Apply schema +changes only through the existing, separately authorized migration workflow. +The CLI supports local data only and refuses --remote. Do not use the unrelated +journey seed script: that script also edits demo timelines and users. + +Prepare a reviewable seed artifact without changing any database: + +```sh +pnpm catalog seed --output .fleet-local/experience-catalog-seed.sql +``` + +After the catalog schema has been applied locally: + +```sh +pnpm catalog seed --local +pnpm catalog pending --local +pnpm catalog review --local --id --decision approve --reviewer +pnpm catalog review --local --id --decision reject --reviewer +``` + +Seed inserts are idempotent and do not overwrite approved data. The CLI records +the supplied operator name and time; this local label is not an authenticated +admin identity. Queue inspection is capped at 100 oldest pending rows. +Authenticated submissions are bounded to twenty per user per day in an atomic +database statement; repeats by the same user do not create duplicate rows. + +While the catalog is absent or the seed import is incomplete, reads use built-in seed ideas and explicitly +return source: seed. They do not send the whole corpus to the client. An +unrelated database failure returns an error instead of silently replacing +shared results. Writes require the new tables; they do not pretend to succeed. +Approval requires the seed import to be complete before publishing new ideas. + +Production migration, seed import, reviewer access and release remain explicit +operator actions. No production database changes were performed here. diff --git a/e2e/experiences.spec.ts b/e2e/experiences.spec.ts index 6bba94a..ab6397c 100644 --- a/e2e/experiences.spec.ts +++ b/e2e/experiences.spec.ts @@ -7,12 +7,27 @@ import { waitForHydrated } from './fixtures/hydration'; * mistake the mortality frame had (decisions.md A9). */ test.describe('Experiences', () => { - test('lists the whole corpus for a signed-out visitor', async ({ page }) => { + test('makes the whole corpus available in bounded pages for a signed-out visitor', async ({ + page, + }) => { await page.goto('/experiences'); await expect(page).toHaveURL(/\/experiences$/); await expect(page.getByRole('heading', { name: 'Things you could do.' })).toBeVisible(); await expect(page.getByText(/^\d+ of \d+$/)).toBeVisible(); await expect(page.locator('main li').first()).toBeVisible(); + await expect(page.locator('main ul').first().locator(':scope > li')).toHaveCount(20); + await expect(page.getByRole('button', { name: 'Previous', exact: true })).toBeDisabled(); + const firstTitle = await page.locator('main ul').first().locator('li').first().textContent(); + await waitForHydrated(page.getByRole('button', { name: 'Next', exact: true })); + await page.getByRole('button', { name: 'Next', exact: true }).click(); + await expect(page.getByText(/^Page 2 of/)).toBeVisible(); + await expect(page.getByText(/^\d+ of \d+$/)).toBeFocused(); + expect(await page.locator('main ul').first().locator('li').first().textContent()).not.toBe( + firstTitle + ); + await expect(page.locator('main ul').first().locator(':scope > li')).toHaveCount(20); + await page.getByRole('button', { name: 'Travel', exact: true }).click(); + await expect(page.getByText(/^Page 1 of/)).toBeVisible(); }); test('exposes exactly one main landmark and one h1', async ({ page }) => { @@ -21,6 +36,49 @@ test.describe('Experiences', () => { await expect(page.locator('main#main h1')).toHaveCount(1); }); + test('last page has no extra rows and cannot advance further', async ({ page }) => { + await page.goto('/experiences'); + const next = page.getByRole('button', { name: 'Next', exact: true }); + await waitForHydrated(next); + const total = Number( + (await page.getByText(/^\d+ of \d+$/).textContent())?.match(/^(\d+)/)?.[1] + ); + const pages = Math.ceil(total / 20); + for (let pageNumber = 1; pageNumber < pages; pageNumber++) await next.click(); + await expect(next).toBeDisabled(); + await expect(page.getByText(`Page ${pages} of ${pages}`, { exact: true })).toBeVisible(); + await expect(page.locator('main ul').first().locator(':scope > li')).toHaveCount( + total % 20 || 20 + ); + }); + + test('preserves filtered pagination across reload and browser back', async ({ page }) => { + await page.goto('/experiences?category=travel&kind=destination&page=2'); + await expect(page.getByText('Page 2 of 4', { exact: true })).toBeVisible(); + await page.reload(); + await expect(page.getByText('Page 2 of 4', { exact: true })).toBeVisible(); + const next = page.getByRole('button', { name: 'Next', exact: true }); + await waitForHydrated(next); + await next.click(); + await expect(page).toHaveURL(/page=3/); + await expect(page.getByText('Page 3 of 4', { exact: true })).toBeVisible(); + await page.goBack(); + await expect(page.getByText('Page 2 of 4', { exact: true })).toBeVisible(); + }); + + test('API returns bounded shared suggestions and rejects oversized pages', async ({ + request, + }) => { + const response = await request.get('/api/experience-catalog?q=pot&pageSize=5'); + expect(response.status()).toBe(200); + const data = await response.json(); + expect(data.items.length).toBeGreaterThan(0); + expect(data.items.length).toBeLessThanOrEqual(5); + expect(['seed', 'database']).toContain(data.source); + expect(JSON.stringify(data)).not.toMatch(/userId|reviewedBy|pending/); + expect((await request.get('/api/experience-catalog?pageSize=100')).status()).toBe(400); + }); + test('search narrows the list and reports the count', async ({ page }) => { await page.goto('/experiences'); const counter = page.getByText(/^\d+ of \d+$/); @@ -93,3 +151,53 @@ test.describe('Experiences', () => { expect(href).toMatch(/^\/experiences\//); }); }); + +test.describe('Manual bucket-list entry', () => { + test('searches, saves locally, then accepts personal wording', async ({ page }) => { + await page.goto('/bucket-list'); + await expect(page.getByLabel('I want to…')).toBeEnabled(); + await waitForHydrated(page.getByLabel('I want to…')); + await page.getByLabel('I want to…').fill('learn'); + await expect(page.getByText(/matching ideas/)).toBeVisible(); + await expect(page.locator('form + div ul > li')).toHaveCount(5); + await page.getByRole('button', { name: 'Next', exact: true }).click(); + await expect(page.getByText(/^Page 2 of/)).toBeVisible(); + await page.getByLabel('I want to…').fill('pottery'); + await page + .getByRole('button', { + name: 'Add Take a pottery class and make a finished piece to my list', + exact: true, + }) + .click(); + await expect( + page.getByText('Take a pottery class and make a finished piece', { exact: true }) + ).toBeVisible(); + await page.getByLabel('I want to…').fill('Teach my niece to make a bowl'); + await page.getByRole('button', { name: 'Add my wording', exact: true }).click(); + await expect(page.getByText('Teach my niece to make a bowl', { exact: true })).toBeVisible(); + await page.reload(); + await expect(page.getByText('Teach my niece to make a bowl', { exact: true })).toBeVisible(); + await expect( + page.getByText('Take a pottery class and make a finished piece', { exact: true }) + ).toBeVisible(); + }); + + test('offers live matches while typing and accepts unmatched personal wording', async ({ + page, + }) => { + await page.goto('/bucket-list'); + await waitForHydrated(page.getByLabel('I want to…')); + await page.getByLabel('I want to…').pressSequentially('pot'); + await expect( + page.getByRole('button', { + name: 'Add Take a pottery class and make a finished piece to my list', + exact: true, + }) + ).toBeVisible(); + await page.getByLabel('I want to…').fill('My very personal zzzzqqqq idea'); + await expect(page.getByText(/No matching ideas/)).toBeVisible(); + await expect(page.getByLabel('I want to…')).toHaveValue('My very personal zzzzqqqq idea'); + await page.getByRole('button', { name: 'Add my wording', exact: true }).click(); + await expect(page.getByText('My very personal zzzzqqqq idea', { exact: true })).toBeVisible(); + }); +}); diff --git a/migrations/d1/0007_wandering_bloodscream.sql b/migrations/d1/0007_wandering_bloodscream.sql new file mode 100644 index 0000000..e20413c --- /dev/null +++ b/migrations/d1/0007_wandering_bloodscream.sql @@ -0,0 +1,33 @@ +CREATE TABLE `CatalogSubmission` ( + `id` text PRIMARY KEY NOT NULL, + `userId` text NOT NULL, + `title` text NOT NULL, + `normalizedTitle` text NOT NULL, + `category` text NOT NULL, + `status` text DEFAULT 'pending' NOT NULL, + `createdAt` integer DEFAULT (unixepoch()) NOT NULL, + `reviewedBy` text, + `reviewedAt` integer, + FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON UPDATE no action ON DELETE cascade, + CONSTRAINT "CatalogSubmission_status_check" CHECK("CatalogSubmission"."status" IN ('pending','approved','rejected')) +); +--> statement-breakpoint +CREATE UNIQUE INDEX `CatalogSubmission_user_title_idx` ON `CatalogSubmission` (`userId`,`normalizedTitle`);--> statement-breakpoint +CREATE INDEX `CatalogSubmission_status_created_idx` ON `CatalogSubmission` (`status`,`createdAt`);--> statement-breakpoint +CREATE INDEX `CatalogSubmission_user_created_idx` ON `CatalogSubmission` (`userId`,`createdAt`);--> statement-breakpoint +CREATE TABLE `ExperienceCatalog` ( + `slug` text PRIMARY KEY NOT NULL, + `title` text NOT NULL, + `normalizedTitle` text NOT NULL, + `description` text, + `searchText` text NOT NULL, + `emoji` text NOT NULL, + `category` text NOT NULL, + `kind` text NOT NULL, + `source` text NOT NULL, + `sortOrder` integer DEFAULT 2147483647 NOT NULL, + `createdAt` integer DEFAULT (unixepoch()) NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `ExperienceCatalog_normalizedTitle_unique` ON `ExperienceCatalog` (`normalizedTitle`);--> statement-breakpoint +CREATE INDEX `ExperienceCatalog_category_kind_idx` ON `ExperienceCatalog` (`category`,`kind`); \ No newline at end of file diff --git a/migrations/d1/meta/0007_snapshot.json b/migrations/d1/meta/0007_snapshot.json new file mode 100644 index 0000000..39e9448 --- /dev/null +++ b/migrations/d1/meta/0007_snapshot.json @@ -0,0 +1,3286 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "bd10d4cc-337c-44b6-9bc5-775e6d039a0f", + "prevId": "1c9287c7-9d9b-459f-8207-548d5926cbb0", + "tables": { + "auth_account": { + "name": "auth_account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "accountId": { + "name": "accountId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerId": { + "name": "providerId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accessToken": { + "name": "accessToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refreshToken": { + "name": "refreshToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "idToken": { + "name": "idToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accessTokenExpiresAt": { + "name": "accessTokenExpiresAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refreshTokenExpiresAt": { + "name": "refreshTokenExpiresAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "auth_account_userId_auth_user_id_fk": { + "name": "auth_account_userId_auth_user_id_fk", + "tableFrom": "auth_account", + "tableTo": "auth_user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "Account": { + "name": "Account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "Account_provider_providerAccountId_key": { + "name": "Account_provider_providerAccountId_key", + "columns": [ + "provider", + "providerAccountId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "Account_userId_User_id_fk": { + "name": "Account_userId_User_id_fk", + "tableFrom": "Account", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "Arc": { + "name": "Arc", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "emoji": { + "name": "emoji", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'custom'" + }, + "sourceBucketItemId": { + "name": "sourceBucketItemId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sourceTimelineId": { + "name": "sourceTimelineId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "startedAt": { + "name": "startedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "completedAt": { + "name": "completedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "Arc_userId_status_idx": { + "name": "Arc_userId_status_idx", + "columns": [ + "userId", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "Arc_userId_User_id_fk": { + "name": "Arc_userId_User_id_fk", + "tableFrom": "Arc", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "BucketListItem": { + "name": "BucketListItem", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'planned'" + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'private'" + }, + "sourceSlug": { + "name": "sourceSlug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sourceItemTitle": { + "name": "sourceItemTitle", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "targetYear": { + "name": "targetYear", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completedAt": { + "name": "completedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "BucketListItem_userId_idx": { + "name": "BucketListItem_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "BucketListItem_userId_User_id_fk": { + "name": "BucketListItem_userId_User_id_fk", + "tableFrom": "BucketListItem", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "BucketList": { + "name": "BucketList", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subtitle": { + "name": "subtitle", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "horizon": { + "name": "horizon", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "boldness": { + "name": "boldness", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "defaultView": { + "name": "defaultView", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'LIST'" + }, + "intentions": { + "name": "intentions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "items": { + "name": "items", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'PRIVATE'" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "BucketList_slug_unique": { + "name": "BucketList_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "BucketList_userId_idx": { + "name": "BucketList_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "BucketList_slug_idx": { + "name": "BucketList_slug_idx", + "columns": [ + "slug" + ], + "isUnique": false + }, + "BucketList_visibility_idx": { + "name": "BucketList_visibility_idx", + "columns": [ + "visibility" + ], + "isUnique": false + } + }, + "foreignKeys": { + "BucketList_userId_User_id_fk": { + "name": "BucketList_userId_User_id_fk", + "tableFrom": "BucketList", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "CatalogSubmission": { + "name": "CatalogSubmission", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "normalizedTitle": { + "name": "normalizedTitle", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "reviewedBy": { + "name": "reviewedBy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reviewedAt": { + "name": "reviewedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "CatalogSubmission_user_title_idx": { + "name": "CatalogSubmission_user_title_idx", + "columns": [ + "userId", + "normalizedTitle" + ], + "isUnique": true + }, + "CatalogSubmission_status_created_idx": { + "name": "CatalogSubmission_status_created_idx", + "columns": [ + "status", + "createdAt" + ], + "isUnique": false + }, + "CatalogSubmission_user_created_idx": { + "name": "CatalogSubmission_user_created_idx", + "columns": [ + "userId", + "createdAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "CatalogSubmission_userId_User_id_fk": { + "name": "CatalogSubmission_userId_User_id_fk", + "tableFrom": "CatalogSubmission", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "CatalogSubmission_status_check": { + "name": "CatalogSubmission_status_check", + "value": "\"CatalogSubmission\".\"status\" IN ('pending','approved','rejected')" + } + } + }, + "Comment": { + "name": "Comment", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timelineId": { + "name": "timelineId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "Comment_userId_idx": { + "name": "Comment_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "Comment_timelineId_idx": { + "name": "Comment_timelineId_idx", + "columns": [ + "timelineId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "Comment_userId_User_id_fk": { + "name": "Comment_userId_User_id_fk", + "tableFrom": "Comment", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "Comment_timelineId_Timeline_id_fk": { + "name": "Comment_timelineId_Timeline_id_fk", + "tableFrom": "Comment", + "tableTo": "Timeline", + "columnsFrom": [ + "timelineId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "Commitment": { + "name": "Commitment", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hobbyName": { + "name": "hobbyName", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "goalDays": { + "name": "goalDays", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 30 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'private'" + }, + "startDate": { + "name": "startDate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "completedAt": { + "name": "completedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "Commitment_userId_idx": { + "name": "Commitment_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "Commitment_status_idx": { + "name": "Commitment_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "Commitment_visibility_idx": { + "name": "Commitment_visibility_idx", + "columns": [ + "visibility" + ], + "isUnique": false + } + }, + "foreignKeys": { + "Commitment_userId_User_id_fk": { + "name": "Commitment_userId_User_id_fk", + "tableFrom": "Commitment", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "DailyCheckin": { + "name": "DailyCheckin", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dayDate": { + "name": "dayDate", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amCompleted": { + "name": "amCompleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "pmCompleted": { + "name": "pmCompleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "DailyCheckin_userId_dayDate_key": { + "name": "DailyCheckin_userId_dayDate_key", + "columns": [ + "userId", + "dayDate" + ], + "isUnique": true + } + }, + "foreignKeys": { + "DailyCheckin_userId_User_id_fk": { + "name": "DailyCheckin_userId_User_id_fk", + "tableFrom": "DailyCheckin", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ExperienceCatalog": { + "name": "ExperienceCatalog", + "columns": { + "slug": { + "name": "slug", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "normalizedTitle": { + "name": "normalizedTitle", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "searchText": { + "name": "searchText", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emoji": { + "name": "emoji", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sortOrder": { + "name": "sortOrder", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 2147483647 + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "ExperienceCatalog_normalizedTitle_unique": { + "name": "ExperienceCatalog_normalizedTitle_unique", + "columns": [ + "normalizedTitle" + ], + "isUnique": true + }, + "ExperienceCatalog_category_kind_idx": { + "name": "ExperienceCatalog_category_kind_idx", + "columns": [ + "category", + "kind" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "Follow": { + "name": "Follow", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "followerId": { + "name": "followerId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "followingId": { + "name": "followingId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "Follow_followerId_followingId_key": { + "name": "Follow_followerId_followingId_key", + "columns": [ + "followerId", + "followingId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "Follow_followerId_User_id_fk": { + "name": "Follow_followerId_User_id_fk", + "tableFrom": "Follow", + "tableTo": "User", + "columnsFrom": [ + "followerId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "Follow_followingId_User_id_fk": { + "name": "Follow_followingId_User_id_fk", + "tableFrom": "Follow", + "tableTo": "User", + "columnsFrom": [ + "followingId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "HabitLog": { + "name": "HabitLog", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "habitId": { + "name": "habitId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dayDate": { + "name": "dayDate", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "completed": { + "name": "completed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "HabitLog_habitId_dayDate_key": { + "name": "HabitLog_habitId_dayDate_key", + "columns": [ + "habitId", + "dayDate" + ], + "isUnique": true + }, + "HabitLog_userId_idx": { + "name": "HabitLog_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "HabitLog_habitId_Habit_id_fk": { + "name": "HabitLog_habitId_Habit_id_fk", + "tableFrom": "HabitLog", + "tableTo": "Habit", + "columnsFrom": [ + "habitId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "HabitLog_userId_User_id_fk": { + "name": "HabitLog_userId_User_id_fk", + "tableFrom": "HabitLog", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "Habit": { + "name": "Habit", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "targetFrequency": { + "name": "targetFrequency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'daily'" + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sourceQuestId": { + "name": "sourceQuestId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commitmentId": { + "name": "commitmentId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "Habit_userId_idx": { + "name": "Habit_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "Habit_sourceQuestId_idx": { + "name": "Habit_sourceQuestId_idx", + "columns": [ + "sourceQuestId" + ], + "isUnique": false + }, + "Habit_commitmentId_idx": { + "name": "Habit_commitmentId_idx", + "columns": [ + "commitmentId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "Habit_userId_User_id_fk": { + "name": "Habit_userId_User_id_fk", + "tableFrom": "Habit", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "Habit_commitmentId_Commitment_id_fk": { + "name": "Habit_commitmentId_Commitment_id_fk", + "tableFrom": "Habit", + "tableTo": "Commitment", + "columnsFrom": [ + "commitmentId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "JournalEntry": { + "name": "JournalEntry", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dayDate": { + "name": "dayDate", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amEntry": { + "name": "amEntry", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pmEntry": { + "name": "pmEntry", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timelineId": { + "name": "timelineId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commitmentId": { + "name": "commitmentId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "noveltyId": { + "name": "noveltyId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "noveltyText": { + "name": "noveltyText", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "noveltyCompleted": { + "name": "noveltyCompleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "JournalEntry_userId_dayDate_key": { + "name": "JournalEntry_userId_dayDate_key", + "columns": [ + "userId", + "dayDate" + ], + "isUnique": true + }, + "JournalEntry_userId_idx": { + "name": "JournalEntry_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "JournalEntry_timelineId_idx": { + "name": "JournalEntry_timelineId_idx", + "columns": [ + "timelineId" + ], + "isUnique": false + }, + "JournalEntry_commitmentId_idx": { + "name": "JournalEntry_commitmentId_idx", + "columns": [ + "commitmentId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "JournalEntry_userId_User_id_fk": { + "name": "JournalEntry_userId_User_id_fk", + "tableFrom": "JournalEntry", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "JournalEntry_timelineId_Timeline_id_fk": { + "name": "JournalEntry_timelineId_Timeline_id_fk", + "tableFrom": "JournalEntry", + "tableTo": "Timeline", + "columnsFrom": [ + "timelineId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "JournalEntry_commitmentId_Commitment_id_fk": { + "name": "JournalEntry_commitmentId_Commitment_id_fk", + "tableFrom": "JournalEntry", + "tableTo": "Commitment", + "columnsFrom": [ + "commitmentId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "JournalEntry_single_context_check": { + "name": "JournalEntry_single_context_check", + "value": "\"JournalEntry\".\"timelineId\" IS NULL OR \"JournalEntry\".\"commitmentId\" IS NULL" + } + } + }, + "Like": { + "name": "Like", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timelineId": { + "name": "timelineId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "Like_userId_timelineId_key": { + "name": "Like_userId_timelineId_key", + "columns": [ + "userId", + "timelineId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "Like_userId_User_id_fk": { + "name": "Like_userId_User_id_fk", + "tableFrom": "Like", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "Like_timelineId_Timeline_id_fk": { + "name": "Like_timelineId_Timeline_id_fk", + "tableFrom": "Like", + "tableTo": "Timeline", + "columnsFrom": [ + "timelineId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "native_atlas_state": { + "name": "native_atlas_state", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "native_atlas_state_user_id_auth_user_id_fk": { + "name": "native_atlas_state_user_id_auth_user_id_fk", + "tableFrom": "native_atlas_state", + "tableTo": "auth_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "native_auth_handoffs": { + "name": "native_auth_handoffs", + "columns": { + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "native_auth_handoffs_expiry_idx": { + "name": "native_auth_handoffs_expiry_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_session": { + "name": "auth_session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ipAddress": { + "name": "ipAddress", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "auth_session_token_unique": { + "name": "auth_session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "auth_session_userId_auth_user_id_fk": { + "name": "auth_session_userId_auth_user_id_fk", + "tableFrom": "auth_session", + "tableTo": "auth_user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "Session": { + "name": "Session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "sessionToken": { + "name": "sessionToken", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "Session_sessionToken_unique": { + "name": "Session_sessionToken_unique", + "columns": [ + "sessionToken" + ], + "isUnique": true + } + }, + "foreignKeys": { + "Session_userId_User_id_fk": { + "name": "Session_userId_User_id_fk", + "tableFrom": "Session", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "Stamp": { + "name": "Stamp", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "commitmentId": { + "name": "commitmentId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dayDate": { + "name": "dayDate", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dayIndex": { + "name": "dayIndex", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "proofUrl": { + "name": "proofUrl", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "proofType": { + "name": "proofType", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'url'" + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "Stamp_commitmentId_dayDate_key": { + "name": "Stamp_commitmentId_dayDate_key", + "columns": [ + "commitmentId", + "dayDate" + ], + "isUnique": true + }, + "Stamp_commitmentId_idx": { + "name": "Stamp_commitmentId_idx", + "columns": [ + "commitmentId" + ], + "isUnique": false + }, + "Stamp_userId_idx": { + "name": "Stamp_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "Stamp_commitmentId_Commitment_id_fk": { + "name": "Stamp_commitmentId_Commitment_id_fk", + "tableFrom": "Stamp", + "tableTo": "Commitment", + "columnsFrom": [ + "commitmentId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "Stamp_userId_User_id_fk": { + "name": "Stamp_userId_User_id_fk", + "tableFrom": "Stamp", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "Timeline": { + "name": "Timeline", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'PRIVATE'" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phases": { + "name": "phases", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "pins": { + "name": "pins", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "versions": { + "name": "versions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "Timeline_slug_unique": { + "name": "Timeline_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "Timeline_userId_idx": { + "name": "Timeline_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "Timeline_slug_idx": { + "name": "Timeline_slug_idx", + "columns": [ + "slug" + ], + "isUnique": false + }, + "Timeline_visibility_idx": { + "name": "Timeline_visibility_idx", + "columns": [ + "visibility" + ], + "isUnique": false + } + }, + "foreignKeys": { + "Timeline_userId_User_id_fk": { + "name": "Timeline_userId_User_id_fk", + "tableFrom": "Timeline", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "TrajectoryContract": { + "name": "TrajectoryContract", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "previousContractId": { + "name": "previousContractId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "constraintsText": { + "name": "constraintsText", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "intentText": { + "name": "intentText", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "decisionPolicyText": { + "name": "decisionPolicyText", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "feedbackLoopText": { + "name": "feedbackLoopText", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cadence": { + "name": "cadence", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "openedAt": { + "name": "openedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "closedAt": { + "name": "closedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "TrajectoryContract_userId_idx": { + "name": "TrajectoryContract_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "TrajectoryContract_one_active_per_user_idx": { + "name": "TrajectoryContract_one_active_per_user_idx", + "columns": [ + "userId" + ], + "isUnique": true, + "where": "\"TrajectoryContract\".\"status\" = 'active'" + } + }, + "foreignKeys": { + "TrajectoryContract_userId_User_id_fk": { + "name": "TrajectoryContract_userId_User_id_fk", + "tableFrom": "TrajectoryContract", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "TrajectoryEntry": { + "name": "TrajectoryEntry", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "eraId": { + "name": "eraId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bucket": { + "name": "bucket", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monthKey": { + "name": "monthKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reflection": { + "name": "reflection", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "numbers": { + "name": "numbers", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "TrajectoryEntry_eraId_monthKey_key": { + "name": "TrajectoryEntry_eraId_monthKey_key", + "columns": [ + "eraId", + "monthKey" + ], + "isUnique": true + }, + "TrajectoryEntry_userId_idx": { + "name": "TrajectoryEntry_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "TrajectoryEntry_userId_bucket_idx": { + "name": "TrajectoryEntry_userId_bucket_idx", + "columns": [ + "userId", + "bucket" + ], + "isUnique": false + } + }, + "foreignKeys": { + "TrajectoryEntry_eraId_TrajectoryEra_id_fk": { + "name": "TrajectoryEntry_eraId_TrajectoryEra_id_fk", + "tableFrom": "TrajectoryEntry", + "tableTo": "TrajectoryEra", + "columnsFrom": [ + "eraId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "TrajectoryEntry_userId_User_id_fk": { + "name": "TrajectoryEntry_userId_User_id_fk", + "tableFrom": "TrajectoryEntry", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "TrajectoryEra": { + "name": "TrajectoryEra", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bucket": { + "name": "bucket", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "idealText": { + "name": "idealText", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "openedAt": { + "name": "openedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "closedAt": { + "name": "closedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "TrajectoryEra_userId_idx": { + "name": "TrajectoryEra_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "TrajectoryEra_userId_bucket_status_idx": { + "name": "TrajectoryEra_userId_bucket_status_idx", + "columns": [ + "userId", + "bucket", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "TrajectoryEra_userId_User_id_fk": { + "name": "TrajectoryEra_userId_User_id_fk", + "tableFrom": "TrajectoryEra", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "TrajectoryReview": { + "name": "TrajectoryReview", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "contractId": { + "name": "contractId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signalText": { + "name": "signalText", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "decision": { + "name": "decision", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "TrajectoryReview_contractId_idx": { + "name": "TrajectoryReview_contractId_idx", + "columns": [ + "contractId" + ], + "isUnique": false + }, + "TrajectoryReview_userId_idx": { + "name": "TrajectoryReview_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "TrajectoryReview_contractId_TrajectoryContract_id_fk": { + "name": "TrajectoryReview_contractId_TrajectoryContract_id_fk", + "tableFrom": "TrajectoryReview", + "tableTo": "TrajectoryContract", + "columnsFrom": [ + "contractId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "TrajectoryReview_userId_User_id_fk": { + "name": "TrajectoryReview_userId_User_id_fk", + "tableFrom": "TrajectoryReview", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_user": { + "name": "auth_user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "auth_user_email_unique": { + "name": "auth_user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "UserQuest": { + "name": "UserQuest", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "questId": { + "name": "questId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'rediscovery'" + }, + "sourceHobby": { + "name": "sourceHobby", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sourceTimelineId": { + "name": "sourceTimelineId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sourceBucketItemId": { + "name": "sourceBucketItemId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "arcId": { + "name": "arcId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "emoji": { + "name": "emoji", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'private'" + }, + "startedAt": { + "name": "startedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "completedAt": { + "name": "completedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "UserQuest_userId_status_idx": { + "name": "UserQuest_userId_status_idx", + "columns": [ + "userId", + "status" + ], + "isUnique": false + }, + "UserQuest_userId_visibility_idx": { + "name": "UserQuest_userId_visibility_idx", + "columns": [ + "userId", + "visibility" + ], + "isUnique": false + }, + "UserQuest_userId_questId_active_key": { + "name": "UserQuest_userId_questId_active_key", + "columns": [ + "userId", + "questId", + "status" + ], + "isUnique": true + } + }, + "foreignKeys": { + "UserQuest_userId_User_id_fk": { + "name": "UserQuest_userId_User_id_fk", + "tableFrom": "UserQuest", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "User": { + "name": "User", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "birthYear": { + "name": "birthYear", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "birthDate": { + "name": "birthDate", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "weekStartsOn": { + "name": "weekStartsOn", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "weeklyEmailOptIn": { + "name": "weeklyEmailOptIn", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "creed": { + "name": "creed", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "onboardingData": { + "name": "onboardingData", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "onboardingCompletedAt": { + "name": "onboardingCompletedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completedQuests": { + "name": "completedQuests", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "earnedBadges": { + "name": "earnedBadges", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "User_email_unique": { + "name": "User_email_unique", + "columns": [ + "email" + ], + "isUnique": true + }, + "User_username_unique": { + "name": "User_username_unique", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_verification": { + "name": "auth_verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "VerificationToken": { + "name": "VerificationToken", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "VerificationToken_token_unique": { + "name": "VerificationToken_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "VerificationToken_identifier_token_pk": { + "columns": [ + "identifier", + "token" + ], + "name": "VerificationToken_identifier_token_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "WeeklyLogEntry": { + "name": "WeeklyLogEntry", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "weekOf": { + "name": "weekOf", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "promptText": { + "name": "promptText", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "turnsJson": { + "name": "turnsJson", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "WeeklyLogEntry_userId_weekOf_key": { + "name": "WeeklyLogEntry_userId_weekOf_key", + "columns": [ + "userId", + "weekOf" + ], + "isUnique": true + }, + "WeeklyLogEntry_userId_idx": { + "name": "WeeklyLogEntry_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "WeeklyLogEntry_userId_User_id_fk": { + "name": "WeeklyLogEntry_userId_User_id_fk", + "tableFrom": "WeeklyLogEntry", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/migrations/d1/meta/_journal.json b/migrations/d1/meta/_journal.json index 00c1679..b4d97cf 100644 --- a/migrations/d1/meta/_journal.json +++ b/migrations/d1/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1789919719966, "tag": "0006_black_proemial_gods", "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1791478392897, + "tag": "0007_wandering_bloodscream", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package.json b/package.json index 09d958f..c1bf217 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "format:check": "biome format .", "check": "biome check .", "content": "tsx scripts/content-cli.ts", + "catalog": "tsx scripts/catalog-cli.ts", "test": "vitest run", "test:coverage": "vitest run --coverage", "test:watch": "vitest", diff --git a/scripts/catalog-cli.ts b/scripts/catalog-cli.ts new file mode 100644 index 0000000..6b54745 --- /dev/null +++ b/scripts/catalog-cli.ts @@ -0,0 +1,65 @@ +#!/usr/bin/env tsx +import { writeFile } from 'node:fs/promises'; +import { getPlatformProxy } from 'wrangler'; +import { catalogSeedSql } from '../src/lib/catalog-seed'; +import { reviewStoredCatalogIdea } from '../src/server/catalog-store'; + +function option(name: string) { + const index = process.argv.indexOf(`--${name}`); + return index < 0 ? undefined : process.argv[index + 1]; +} +function required(name: string) { + const value = option(name); + if (!value || value.startsWith('--')) throw new Error(`Missing --${name}`); + return value; +} + +async function main() { + const command = process.argv[2]; + if (!['seed', 'pending', 'review'].includes(command ?? '')) + throw new Error('Use seed, pending or review'); + if (process.argv.includes('--remote')) + throw new Error( + 'This tool only supports local data. Production writes require a separate operator release.' + ); + if (command === 'seed' && option('output')) { + const path = required('output'); + if (!path.endsWith('.sql')) throw new Error('Seed output must be a .sql artifact'); + await writeFile(path, catalogSeedSql()); + console.log(`Seed SQL prepared at ${path}; no database was changed.`); + return; + } + if (!process.argv.includes('--local')) + throw new Error('Pass --local explicitly to operate on local data'); + const platform = await getPlatformProxy({ configPath: './wrangler.local.toml' }); + try { + if (command === 'seed') { + await platform.env.DB.exec(catalogSeedSql()); + console.log( + 'Seed ideas inserted locally; existing catalog entries and personal lists were preserved.' + ); + } else if (command === 'pending') { + const result = await platform.env.DB.prepare( + "SELECT id,title,category,createdAt FROM CatalogSubmission WHERE status = 'pending' ORDER BY createdAt LIMIT 100" + ).all(); + console.log(JSON.stringify(result.results, null, 2)); + } else { + const decision = required('decision'); + if (decision !== 'approve' && decision !== 'reject') + throw new Error('Decision must be approve or reject'); + await reviewStoredCatalogIdea( + platform.env.DB, + required('id'), + decision, + required('reviewer') + ); + console.log(`Local submission ${decision}d.`); + } + } finally { + await platform.dispose(); + } +} +main().catch((error) => { + console.error(error instanceof Error ? error.message : 'Catalog operation failed'); + process.exitCode = 1; +}); diff --git a/src/app/api/experience-catalog/route.test.ts b/src/app/api/experience-catalog/route.test.ts new file mode 100644 index 0000000..5f8cd92 --- /dev/null +++ b/src/app/api/experience-catalog/route.test.ts @@ -0,0 +1,19 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { GET } from './route'; + +const search = vi.hoisted(() => vi.fn()); +vi.mock('~/server/experience-catalog', () => ({ searchExperienceCatalog: search })); +beforeEach(() => vi.clearAllMocks()); +describe('Public catalog API', () => { + it('rejects invalid pagination without accessing storage', async () => { + const result = await GET(new Request('http://localhost/api/experience-catalog?pageSize=100')); + expect(result.status).toBe(400); + expect(search).not.toHaveBeenCalled(); + }); + it('does not expose backend error details on failure', async () => { + search.mockRejectedValue(new Error('Internal database information')); + const result = await GET(new Request('http://localhost/api/experience-catalog')); + expect(result.status).toBe(503); + expect(await result.text()).not.toContain('Internal database information'); + }); +}); diff --git a/src/app/api/experience-catalog/route.ts b/src/app/api/experience-catalog/route.ts new file mode 100644 index 0000000..a0665ea --- /dev/null +++ b/src/app/api/experience-catalog/route.ts @@ -0,0 +1,18 @@ +import { parseCatalogQuery } from '~/lib/catalog-seed'; +import { searchExperienceCatalog } from '~/server/experience-catalog'; + +export async function GET(request: Request) { + let query: ReturnType; + try { + query = parseCatalogQuery(new URL(request.url).searchParams); + } catch { + return Response.json({ error: 'Invalid catalog filters' }, { status: 400 }); + } + try { + return Response.json(await searchExperienceCatalog(query), { + headers: { 'Cache-Control': 'no-store' }, + }); + } catch { + return Response.json({ error: 'Ideas could not be loaded. Try again.' }, { status: 503 }); + } +} diff --git a/src/app/experiences/experiences-client.tsx b/src/app/experiences/experiences-client.tsx index fae770e..01a8025 100644 --- a/src/app/experiences/experiences-client.tsx +++ b/src/app/experiences/experiences-client.tsx @@ -2,13 +2,16 @@ import Link from 'next/link'; import { AddToMyListButton } from '~/components/add-to-my-list-button'; -import { useMemo, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { ResultPagination } from '~/components/result-pagination'; +import { useCatalogResults } from '~/components/use-catalog-results'; +import type { CatalogPage, CatalogQuery } from '~/lib/catalog-types'; -import type { ExperienceCategory, ExperienceEntry, ExperienceKind } from '~/lib/experiences'; +import type { ExperienceCategory, ExperienceKind } from '~/lib/experiences'; type Props = { - entries: ExperienceEntry[]; - categories: ExperienceCategory[]; + initialPage: CatalogPage; + initialQuery: CatalogQuery; mode: 'account' | 'local'; }; @@ -27,25 +30,57 @@ const KINDS: { id: ExperienceKind | 'all'; label: string }[] = [ * useful even when there is nothing more to say about it yet. Entries with a * page link to it; the rest render as plain rows. * - * Filtering is client-side over a few hundred rows, which is far cheaper than a - * round trip and keeps the whole thing usable with the keyboard. + * Both transfer and mounted rows are bounded by server-side pagination. + * Query/filter/page state lives in the URL for reloads and browser navigation. */ -export function ExperiencesClient({ entries, categories, mode }: Props) { - const [query, setQuery] = useState(''); - const [category, setCategory] = useState('all'); - const [kind, setKind] = useState('all'); - - const filtered = useMemo(() => { - const q = query.trim().toLowerCase(); - return entries.filter((e) => { - if (category !== 'all' && e.category !== category) return false; - if (kind !== 'all' && e.kind !== kind) return false; - if (!q) return true; - return ( - e.title.toLowerCase().includes(q) || (e.description?.toLowerCase().includes(q) ?? false) - ); - }); - }, [entries, query, category, kind]); +export function ExperiencesClient({ initialPage, initialQuery, mode }: Props) { + const [query, setQuery] = useState(initialQuery.query); + const [category, setCategory] = useState(initialQuery.category); + const [kind, setKind] = useState(initialQuery.kind); + const [page, setPage] = useState(initialQuery.page); + const pageSize = 20; + const resultSummary = useRef(null); + const catalog = useCatalogResults({ query, category, kind, page, pageSize }, true, initialPage); + const data = catalog.data; + const filtered = data?.items ?? []; + const categories = initialPage.categories; + const pageFocus = useRef(false); + function updateUrl(next: Partial, push = false) { + const params = new URLSearchParams(window.location.search); + const state = { query, category, kind, page, ...next }; + for (const [name, value] of Object.entries({ + q: state.query, + category: state.category, + kind: state.kind, + page: state.page, + })) { + if (!value || value === 'all' || (name === 'page' && value === 1)) params.delete(name); + else params.set(name, String(value)); + } + const url = `${window.location.pathname}${params.size ? `?${params}` : ''}`; + if (push) window.history.pushState(null, '', url); + else window.history.replaceState(null, '', url); + } + useEffect(() => { + function restore() { + const params = new URLSearchParams(window.location.search); + setQuery((params.get('q') ?? '').slice(0, 200)); + const nextCategory = params.get('category'); + setCategory(categories.find((item) => item.id === nextCategory)?.id ?? 'all'); + setKind(KINDS.find((item) => item.id === params.get('kind'))?.id ?? 'all'); + const nextPage = Number(params.get('page') ?? 1); + setPage(Number.isSafeInteger(nextPage) && nextPage > 0 ? nextPage : 1); + } + window.addEventListener('popstate', restore); + return () => window.removeEventListener('popstate', restore); + }, [categories]); + useEffect(() => { + if (data && pageFocus.current) { + pageFocus.current = false; + resultSummary.current?.focus({ preventScroll: true }); + resultSummary.current?.scrollIntoView({ block: 'start' }); + } + }, [data]); return (
@@ -57,8 +92,13 @@ export function ExperiencesClient({ entries, categories, mode }: Props) { setQuery(e.target.value)} + onChange={(e) => { + setQuery(e.target.value); + setPage(1); + updateUrl({ query: e.target.value, page: 1 }); + }} placeholder="Search — northern lights, marathon, learn…" className="w-full rounded-xl border-2 border-border bg-card px-4 py-3 text-base text-foreground placeholder:text-muted-foreground/60 focus-visible:border-primary focus-visible:outline-none" /> @@ -66,29 +106,65 @@ export function ExperiencesClient({ entries, categories, mode }: Props) { {KINDS.map((k) => ( - setKind(k.id)}> + { + setKind(k.id); + setPage(1); + updateUrl({ kind: k.id, page: 1 }); + }} + > {k.label} ))} - setCategory('all')}> + { + setCategory('all'); + setPage(1); + updateUrl({ category: 'all', page: 1 }); + }} + > All {categories.map((c) => ( - setCategory(c)}> - {c[0].toUpperCase() + c.slice(1)} + { + setCategory(c.id); + setPage(1); + updateUrl({ category: c.id, page: 1 }); + }} + > + {c.label} ))}
-

- {filtered.length} of {entries.length} +

+ {catalog.loading + ? 'Loading ideas…' + : catalog.error || `${data?.matches ?? 0} of ${data?.total ?? 0}`}

+ {filtered.length > 0 && ( +

+ Showing {((data?.page ?? 1) - 1) * pageSize + 1}– + {Math.min((data?.page ?? 1) * pageSize, data?.matches ?? 0)} +

+ )} - {filtered.length === 0 ? ( + {filtered.length === 0 && !catalog.loading && !catalog.error ? (

Nothing matches that. Try a shorter word, or clear the filters.

@@ -131,6 +207,15 @@ export function ExperiencesClient({ entries, categories, mode }: Props) { ))} )} + { + setPage(nextPage); + pageFocus.current = true; + updateUrl({ page: nextPage }, true); + }} + /> ); } diff --git a/src/app/experiences/page.tsx b/src/app/experiences/page.tsx index 8cb13df..3bed97e 100644 --- a/src/app/experiences/page.tsx +++ b/src/app/experiences/page.tsx @@ -6,7 +6,8 @@ import { JsonLd } from '~/components/json-ld'; import { EXPERIENCE_COLLECTIONS } from '~/lib/experience-collections'; import { EXPERIENCE_CONTENT_UPDATED } from '~/lib/experience-guides'; -import { EXPERIENCE_CATEGORIES, EXPERIENCE_ENTRIES } from '~/lib/experiences'; +import { parseCatalogQuery } from '~/lib/catalog-seed'; +import { searchExperienceCatalog } from '~/server/experience-catalog'; import { DEFAULT_SOCIAL_IMAGE, SITE_URL } from '~/lib/site-metadata'; import { ExperiencesClient } from './experiences-client'; @@ -31,8 +32,26 @@ export const metadata: Metadata = { }, }; -export default async function ExperiencesPage() { - const session = await getServerAuthSession(); +export default async function ExperiencesPage({ + searchParams, +}: { + searchParams: Promise>; +}) { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(await searchParams)) + if (typeof value === 'string') params.set(key, value); + // Ignore malformed entry URLs without rendering an error page. + let query: ReturnType; + try { + query = parseCatalogQuery(params); + } catch { + query = parseCatalogQuery(new URLSearchParams()); + } + query.pageSize = 20; + const [session, catalog] = await Promise.all([ + getServerAuthSession(), + searchExperienceCatalog(query), + ]); return (

- {EXPERIENCE_ENTRIES.length} of them, in one place — places to go, milestones worth reaching, - and ideas worth stealing. Each has a page of its own with a first step you could take this - week. + {catalog.total} ideas in one shared catalog — places to go, milestones worth reaching, and + things worth trying. Explore by category, and keep the ones that matter in your own list.

@@ -69,8 +87,8 @@ export default async function ExperiencesPage() {

diff --git a/src/components/bucket-list/local-bucket-list.tsx b/src/components/bucket-list/local-bucket-list.tsx index 4e54aed..13e73e0 100644 --- a/src/components/bucket-list/local-bucket-list.tsx +++ b/src/components/bucket-list/local-bucket-list.tsx @@ -1,15 +1,17 @@ 'use client'; -import { Check, Plus, Trash2 } from 'lucide-react'; +import { Check, Trash2 } from 'lucide-react'; import Link from 'next/link'; -import { type FormEvent, useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { ManualItemInput } from './manual-item-input'; import { StorageModeProvider, StorageModeStatus } from '~/components/storage-mode-provider'; import { browserRecordAdapter, readLocalRecord, writeLocalRecord } from '~/lib/local-record-store'; type LocalBucketItem = { title: string; - status?: 'planned' | 'done'; + status?: 'planned' | 'in_progress' | 'done'; + [key: string]: unknown; }; type LocalBucketRecord = { @@ -20,7 +22,6 @@ type LocalBucketRecord = { export function LocalBucketList() { const [record, setRecord] = useState({ items: [] }); - const [title, setTitle] = useState(''); const [loaded, setLoaded] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); @@ -72,15 +73,6 @@ export function LocalBucketList() { } } - async function addItem(event: FormEvent) { - event.preventDefault(); - const nextTitle = title.trim(); - if (!nextTitle || items.some((item) => item.title.toLowerCase() === nextTitle.toLowerCase())) { - return; - } - if (await persist([...items, { title: nextTitle, status: 'planned' }])) setTitle(''); - } - return (
@@ -99,29 +91,22 @@ export function LocalBucketList() {

-
- - + setTitle(event.target.value)} - placeholder="Something I want to do…" - className="min-h-12 min-w-0 flex-1 rounded-xl border border-[#d9cfbd] bg-white px-4 text-base outline-none focus:border-[#176b4a] focus:ring-2 focus:ring-[#176b4a]/20" + onAdd={async (item) => { + if ( + items.some( + (candidate) => candidate.title.toLowerCase() === item.title.toLowerCase() + ) + ) { + setError('This item is already in your list.'); + return false; + } + return persist([...items, { ...item, status: 'planned' }]); + }} /> - - +
{error && (

@@ -139,7 +124,7 @@ export function LocalBucketList() { href="/experiences" className="inline-flex min-h-10 items-center text-sm font-bold underline underline-offset-4" > - Discover more possibilities + Explore ideas

@@ -208,7 +193,7 @@ function normalizeItems(items: LocalBucketRecord['items']): LocalBucketItem[] { return items.flatMap((item) => { if (typeof item === 'string') return [{ title: item, status: 'planned' as const }]; return typeof item?.title === 'string' - ? [{ title: item.title, status: item.status === 'done' ? 'done' : 'planned' }] + ? [{ ...item, title: item.title, status: item.status ?? 'planned' }] : []; }); } diff --git a/src/components/bucket-list/manual-item-input.tsx b/src/components/bucket-list/manual-item-input.tsx new file mode 100644 index 0000000..a809ef5 --- /dev/null +++ b/src/components/bucket-list/manual-item-input.tsx @@ -0,0 +1,241 @@ +'use client'; + +import { useEffect, useId, useRef, useState } from 'react'; +import Link from 'next/link'; +import type { ExperienceEntry } from '~/lib/experiences'; +import { ResultPagination } from '~/components/result-pagination'; +import { useCatalogResults } from '~/components/use-catalog-results'; +import { submitCatalogIdea } from '~/lib/actions/catalog'; +import type { CatalogPage } from '~/lib/catalog-types'; + +type ItemInput = Pick & + Partial>; +const PAGE_SIZE = 5; + +export function ManualItemInput({ + onAdd, + disabled = false, + dark = false, + canSubmitToCatalog = false, +}: { + onAdd: (item: ItemInput) => Promise; + disabled?: boolean; + dark?: boolean; + canSubmitToCatalog?: boolean; +}) { + const id = useId(); + const [value, setValue] = useState(''); + const [page, setPage] = useState(1); + const [pending, setPending] = useState(false); + const [message, setMessage] = useState(''); + const saving = useRef(false); + const catalog = useCatalogResults( + { query: value, category: 'all', kind: 'all', page, pageSize: PAGE_SIZE }, + Boolean(value.trim()) + ); + const results = catalog.data?.items ?? []; + const previousCategories = useRef([]); + const categories = catalog.data?.categories ?? previousCategories.current; + useEffect(() => { + if (catalog.data) previousCategories.current = catalog.data.categories; + }, [catalog.data]); + const [submissionCategory, setSubmissionCategory] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [submittedTitle, setSubmittedTitle] = useState(''); + const [submissionMessage, setSubmissionMessage] = useState(''); + + async function submitForReview() { + if (!value.trim() || !submissionCategory || submitting) return; + setSubmitting(true); + setSubmissionMessage(''); + try { + const result = await submitCatalogIdea({ title: value.trim(), category: submissionCategory }); + setSubmittedTitle(value.trim()); + setSubmissionMessage( + result.status === 'already-shared' || result.status === 'approved' + ? 'This idea is already in the shared catalog.' + : result.status === 'rejected' + ? 'This idea was previously reviewed and declined.' + : 'Submitted for review. It will appear for everyone only after approval. Your personal list is unchanged.' + ); + } catch { + setSubmissionMessage( + 'Could not submit this idea. Your personal list is unchanged; try again later.' + ); + } finally { + setSubmitting(false); + } + } + + async function add(item: ItemInput) { + if (disabled || saving.current || submitting) return; + saving.current = true; + setPending(true); + setMessage(''); + try { + if (await onAdd(item)) { + setMessage(`Added “${item.title}” to your list.`); + setValue(''); + setPage(1); + } else { + setMessage('This item was not added. Check the message below and try again.'); + } + } catch { + setMessage('Could not save this item. Your input is unchanged. Try again.'); + } finally { + saving.current = false; + setPending(false); + } + } + + return ( +
+
+

Type to find ideas, or add your own wording.

+ + Explore for inspiration → + +
+
{ + event.preventDefault(); + if (value.trim()) void add({ title: value.trim() }); + }} + className={`flex flex-col gap-3 rounded-2xl p-4 sm:flex-row sm:items-center ${dark ? 'bg-[#211e18] text-white' : 'border border-[#d9cfbd] bg-[#fffdf8]'}`} + > + + { + setValue(event.target.value); + setPage(1); + setMessage(''); + setSubmissionMessage(''); + }} + placeholder="Something I want to do…" + className={`min-h-11 min-w-0 flex-1 rounded-xl border px-4 text-base focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#176b4a] ${dark ? 'border-white/20 bg-white/10 text-white placeholder:text-white/60' : 'border-[#d9cfbd] bg-white'}`} + /> + +
+ {value.trim() && ( +
+

+ {catalog.loading + ? 'Looking for ideas…' + : catalog.error || `${catalog.data?.matches ?? 0} matching ideas`} +

+ {results.length ? ( +
    + {results.map((entry) => ( +
  • +
    +

    {entry.title}

    +

    {entry.category}

    +
    + +
  • + ))} +
+ ) : !catalog.loading && !catalog.error ? ( +

+ No matching ideas. You can still add your own wording above. +

+ ) : null} + +
+ )} + {value.trim() && canSubmitToCatalog && ( +
+ Suggest this idea to everyone +

+ Only this title and its category will enter review. Your personal list and notes stay + private. +

+
+ + + +
+ {submissionMessage && ( +

+ {submissionMessage} +

+ )} +
+ )} + {value.trim() && !canSubmitToCatalog && ( +

+ + Sign in + {' '} + to submit an idea to the shared catalog for review. +

+ )} + {message && ( +

+ {message} +

+ )} +
+ ); +} diff --git a/src/components/bucket-list/quick-add-item.test.tsx b/src/components/bucket-list/quick-add-item.test.tsx new file mode 100644 index 0000000..c9b31b9 --- /dev/null +++ b/src/components/bucket-list/quick-add-item.test.tsx @@ -0,0 +1,166 @@ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { QuickAddBucketItem } from './quick-add-item'; +import { parseCatalogQuery, searchCatalogSeed } from '~/lib/catalog-seed'; + +const { addItem, refresh, submit } = vi.hoisted(() => ({ + addItem: vi.fn(), + refresh: vi.fn(), + submit: vi.fn(), +})); +vi.mock('~/lib/actions/bucket-list', () => ({ addBucketListItem: addItem })); +vi.mock('next/navigation', () => ({ useRouter: () => ({ refresh }) })); +vi.mock('~/lib/actions/catalog', () => ({ submitCatalogIdea: submit })); + +let container: HTMLDivElement; +let root: Root; + +beforeEach(async () => { + vi.clearAllMocks(); + vi.useFakeTimers(); + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string) => ({ + ok: true, + json: async () => + searchCatalogSeed(parseCatalogQuery(new URL(url, 'http://localhost').searchParams)), + })) + ); + addItem.mockResolvedValue({ id: 'saved' }); + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + await act(async () => root.render()); +}); +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +async function input(value: string) { + const element = container.querySelector('input'); + if (!element) throw new Error('Missing input'); + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(element, value); + element.dispatchEvent(new Event('input', { bubbles: true })); + }); + await act(async () => vi.advanceTimersByTimeAsync(160)); +} +async function click(label: string) { + const button = [...container.querySelectorAll('button')].find( + (element) => element.textContent === label + ); + if (!button) throw new Error(`Missing button: ${label}`); + await act(async () => button.click()); +} + +describe('Account manual entry', () => { + it('saves exact custom wording and refreshes after success', async () => { + await input(' Teach my niece to make a bowl '); + await click('Add my wording'); + expect(addItem).toHaveBeenCalledWith({ title: 'Teach my niece to make a bowl' }); + expect(refresh).toHaveBeenCalledOnce(); + expect(container.querySelector('input')?.value).toBe(''); + expect(submit).not.toHaveBeenCalled(); + }); + + it('passes the selected catalog category and description to account storage', async () => { + await input('pottery'); + expect(container.querySelectorAll('li').length).toBeGreaterThan(0); + await click('+ Add'); + expect(addItem).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Take a pottery class and make a finished piece', + category: 'creative', + }) + ); + expect(refresh).toHaveBeenCalledOnce(); + }); + + it('shows server-paginated suggestions after the first page', async () => { + await input('learn'); + const firstTitle = container.querySelector('li')?.textContent; + expect(container.querySelectorAll('li')).toHaveLength(5); + await click('Next'); + await act(async () => vi.advanceTimersByTimeAsync(160)); + expect(container.querySelectorAll('li')).toHaveLength(5); + expect(container.querySelector('li')?.textContent).not.toBe(firstTitle); + expect(container.textContent).toContain('Page 2 of'); + }); + + it('retains input and exposes feedback when saving fails', async () => { + addItem.mockRejectedValue(new Error('Unavailable')); + await input('My personal idea'); + await click('Add my wording'); + expect(container.querySelector('input')?.value).toBe('My personal idea'); + expect(container.querySelector('[role="status"]')?.textContent).toContain('Could not save'); + expect(refresh).not.toHaveBeenCalled(); + }); + + it('submits only with an explicit category and action, independently of the private save', async () => { + submit.mockResolvedValue({ id: 'submission', status: 'pending' }); + await input('Make a neighbourhood cookbook'); + expect(submit).not.toHaveBeenCalled(); + const select = container.querySelector('select'); + if (!select) throw new Error('Missing category'); + await act(async () => { + select.value = 'creative'; + select.dispatchEvent(new Event('change', { bubbles: true })); + }); + await click('Submit for review'); + expect(submit).toHaveBeenCalledWith({ + title: 'Make a neighbourhood cookbook', + category: 'creative', + }); + expect(addItem).not.toHaveBeenCalled(); + expect(container.textContent).toContain('only after approval'); + }); + + it('allows personal wording to be saved even when shared search is unavailable', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ ok: false })) + ); + await input('My own quiet afternoon'); + expect(container.textContent).toContain('Ideas could not be loaded'); + await click('Add my wording'); + expect(addItem).toHaveBeenCalledWith({ title: 'My own quiet afternoon' }); + expect(submit).not.toHaveBeenCalled(); + }); + + it('keeps the latest suggestions when an older search finishes late', async () => { + const pending = new Map void>(); + vi.stubGlobal( + 'fetch', + vi.fn( + (url: string) => + new Promise((resolve) => { + pending.set(new URL(url, 'http://localhost').searchParams.get('q') ?? '', resolve); + }) + ) + ); + await input('learn'); + await input('pottery'); + await act(async () => + pending.get('pottery')?.( + Response.json( + searchCatalogSeed(parseCatalogQuery(new URLSearchParams('q=pottery&pageSize=5'))) + ) + ) + ); + expect(container.textContent).toContain('Take a pottery class'); + await act(async () => + pending.get('learn')?.( + Response.json( + searchCatalogSeed(parseCatalogQuery(new URLSearchParams('q=learn&pageSize=5'))) + ) + ) + ); + expect(container.textContent).toContain('Take a pottery class'); + expect(container.textContent).not.toContain('Learn calligraphy'); + }); +}); diff --git a/src/components/bucket-list/quick-add-item.tsx b/src/components/bucket-list/quick-add-item.tsx index 7929d99..af5e0f0 100644 --- a/src/components/bucket-list/quick-add-item.tsx +++ b/src/components/bucket-list/quick-add-item.tsx @@ -1,47 +1,20 @@ 'use client'; -import { Plus } from 'lucide-react'; import { useRouter } from 'next/navigation'; -import { useState, useTransition } from 'react'; +import { ManualItemInput } from './manual-item-input'; import { addBucketListItem } from '~/lib/actions/bucket-list'; export function QuickAddBucketItem() { const router = useRouter(); - const [title, setTitle] = useState(''); - const [pending, startTransition] = useTransition(); return ( -
{ - event.preventDefault(); - const value = title.trim(); - if (!value) return; - startTransition(async () => { - await addBucketListItem({ title: value }); - setTitle(''); - router.refresh(); - }); + { + await addBucketListItem(item); + router.refresh(); + return true; }} - className="flex flex-col gap-3 rounded-2xl bg-[#211e18] p-4 text-white sm:flex-row sm:items-center" - > - - setTitle(event.target.value)} - maxLength={200} - placeholder="see the northern lights" - className="min-h-11 flex-1 rounded-xl border border-white/20 bg-white/10 px-4 text-base text-white placeholder:text-white/45 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#f7e957]" - /> - - + /> ); } diff --git a/src/components/nav.tsx b/src/components/nav.tsx index 31b12f2..756fcbf 100644 --- a/src/components/nav.tsx +++ b/src/components/nav.tsx @@ -16,7 +16,7 @@ import { NavLinks } from './nav-links'; import { NavSignOut } from './nav-sign-out'; const NAV_LINKS = [ - { href: '/experiences', label: 'Catalog' }, + { href: '/experiences', label: 'Explore' }, { href: '/bucket-list', label: 'My list' }, { href: '/journal', label: 'Weekly journal' }, { href: '/life-in-weeks', label: 'Life in weeks' }, diff --git a/src/components/result-pagination.tsx b/src/components/result-pagination.tsx new file mode 100644 index 0000000..0bc35c9 --- /dev/null +++ b/src/components/result-pagination.tsx @@ -0,0 +1,39 @@ +'use client'; + +export function ResultPagination({ + page, + pageCount, + onChange, +}: { + page: number; + pageCount: number; + onChange: (page: number) => void; +}) { + if (pageCount <= 1) return null; + return ( + + ); +} diff --git a/src/components/site-footer.tsx b/src/components/site-footer.tsx index 3796493..456bba7 100644 --- a/src/components/site-footer.tsx +++ b/src/components/site-footer.tsx @@ -5,7 +5,7 @@ const groups = [ { title: 'Start here', links: [ - ['Catalog', '/experiences'], + ['Explore', '/experiences'], ['My list', '/bucket-list'], ['Weekly journal', '/journal'], ], diff --git a/src/components/use-catalog-results.ts b/src/components/use-catalog-results.ts new file mode 100644 index 0000000..2e92591 --- /dev/null +++ b/src/components/use-catalog-results.ts @@ -0,0 +1,64 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import type { CatalogPage, CatalogQuery } from '~/lib/catalog-types'; + +export function catalogSearchParams(query: CatalogQuery) { + return new URLSearchParams({ + q: query.query.trim(), + category: query.category, + kind: query.kind, + page: String(query.page), + pageSize: String(query.pageSize), + }); +} + +export function useCatalogResults(query: CatalogQuery, enabled = true, initialData?: CatalogPage) { + const key = catalogSearchParams(query).toString(); + const initialKey = useRef(initialData ? key : ''); + const initialPage = useRef(initialData); + const [result, setResult] = useState<{ + key: string; + data?: CatalogPage; + loading: boolean; + error: string; + }>({ + key: initialData ? key : '', + data: initialData, + loading: enabled && !initialData, + error: '', + }); + useEffect(() => { + if (!enabled) return; + if (initialPage.current && key === initialKey.current) { + setResult({ key, data: initialPage.current, loading: false, error: '' }); + return; + } + const controller = new AbortController(); + setResult({ key, loading: true, error: '' }); + const timeout = setTimeout(async () => { + try { + const response = await fetch(`/api/experience-catalog?${key}`, { + signal: controller.signal, + }); + if (!response.ok) throw new Error('Catalog unavailable'); + const data: CatalogPage = await response.json(); + if (!controller.signal.aborted) setResult({ key, data, loading: false, error: '' }); + } catch { + if (!controller.signal.aborted) + setResult({ + key, + loading: false, + error: 'Ideas could not be loaded. Try again in a moment.', + }); + } + }, 150); + return () => { + clearTimeout(timeout); + controller.abort(); + }; + }, [key, enabled]); + return enabled && result.key === key + ? result + : { key, loading: enabled, error: '', data: undefined }; +} diff --git a/src/db/schema.ts b/src/db/schema.ts index c7c6c6f..3cce592 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -311,6 +311,51 @@ export const bucketListItems = sqliteTable( (table) => [index('BucketListItem_userId_idx').on(table.userId)] ); +// Shared approved ideas and an entirely separate private review queue. +export const experienceCatalog = sqliteTable( + 'ExperienceCatalog', + { + slug: text('slug').primaryKey(), + title: text('title').notNull(), + normalizedTitle: text('normalizedTitle').notNull().unique(), + description: text('description'), + searchText: text('searchText').notNull(), + emoji: text('emoji').notNull(), + category: text('category').notNull(), + kind: text('kind').notNull(), + source: text('source').notNull(), + sortOrder: integer('sortOrder').notNull().default(2147483647), + createdAt: integer('createdAt').notNull().default(sql`(unixepoch())`), + }, + (table) => [index('ExperienceCatalog_category_kind_idx').on(table.category, table.kind)] +); + +export const catalogSubmissions = sqliteTable( + 'CatalogSubmission', + { + id: text('id').primaryKey(), + userId: text('userId') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + title: text('title').notNull(), + normalizedTitle: text('normalizedTitle').notNull(), + category: text('category').notNull(), + status: text('status').notNull().default('pending'), + createdAt: integer('createdAt').notNull().default(sql`(unixepoch())`), + reviewedBy: text('reviewedBy'), + reviewedAt: integer('reviewedAt'), + }, + (table) => [ + uniqueIndex('CatalogSubmission_user_title_idx').on(table.userId, table.normalizedTitle), + index('CatalogSubmission_status_created_idx').on(table.status, table.createdAt), + index('CatalogSubmission_user_created_idx').on(table.userId, table.createdAt), + check( + 'CatalogSubmission_status_check', + sql`${table.status} IN ('pending','approved','rejected')` + ), + ] +); + // ─── Commitments & Stamps ───────────────────────────────────────────────── // A "commitment" is a multi-day goal to show up daily for a hobby // (e.g. "30 days of guitar"). Each calendar day the user logs a "stamp" — diff --git a/src/lib/actions/catalog.test.ts b/src/lib/actions/catalog.test.ts new file mode 100644 index 0000000..892727a --- /dev/null +++ b/src/lib/actions/catalog.test.ts @@ -0,0 +1,44 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { submitCatalogIdea } from './catalog'; + +const { session, submit, database } = vi.hoisted(() => ({ + session: vi.fn(), + submit: vi.fn(), + database: {}, +})); +vi.mock('~/server/auth', () => ({ getServerAuthSession: session })); +vi.mock('~/server/experience-catalog', () => ({ catalogDatabase: () => database })); +vi.mock('~/server/catalog-store', () => ({ submitStoredCatalogIdea: submit })); +beforeEach(() => { + vi.clearAllMocks(); + session.mockResolvedValue({ user: { id: 'alice' } }); + submit.mockResolvedValue({ id: 'idea', status: 'pending' }); +}); + +describe('Explicit catalog submissions', () => { + it('requires an authenticated user', async () => { + session.mockResolvedValue(null); + await expect( + submitCatalogIdea({ title: 'Make a neighbourhood cookbook', category: 'creative' }) + ).rejects.toThrow('Sign in'); + expect(submit).not.toHaveBeenCalled(); + }); + it('uses the session owner and validates the submitted fields', async () => { + expect( + await submitCatalogIdea({ title: ' Make a neighbourhood cookbook ', category: 'creative' }) + ).toEqual({ id: 'idea', status: 'pending' }); + expect(submit).toHaveBeenCalledWith( + database, + 'alice', + 'Make a neighbourhood cookbook', + 'creative' + ); + await expect(submitCatalogIdea({ title: 'abc', category: 'unknown' })).rejects.toThrow(); + }); + it('does not pretend to accept a submission before its schema exists', async () => { + submit.mockRejectedValue(new Error('D1_ERROR: no such table: CatalogSubmission')); + await expect( + submitCatalogIdea({ title: 'Make a neighbourhood cookbook', category: 'creative' }) + ).rejects.toThrow('not set up yet'); + }); +}); diff --git a/src/lib/actions/catalog.ts b/src/lib/actions/catalog.ts new file mode 100644 index 0000000..2a1da08 --- /dev/null +++ b/src/lib/actions/catalog.ts @@ -0,0 +1,36 @@ +'use server'; + +import { z } from 'zod'; +import { EXPERIENCE_CATEGORIES } from '~/lib/experiences'; +import { normalizeCatalogTitle } from '~/lib/catalog-seed'; +import { getServerAuthSession } from '~/server/auth'; +import { catalogDatabase } from '~/server/experience-catalog'; +import { submitStoredCatalogIdea } from '~/server/catalog-store'; + +const submissionSchema = z.object({ + title: z.string().trim().min(3).max(200), + category: z.enum(EXPERIENCE_CATEGORIES), +}); + +export async function submitCatalogIdea(input: { title: string; category: string }) { + const session = await getServerAuthSession(); + if (!session?.user.id) throw new Error('Sign in to submit an idea for review.'); + const parsed = submissionSchema.parse(input); + if (!normalizeCatalogTitle(parsed.title)) throw new Error('Give your idea a title.'); + try { + return await submitStoredCatalogIdea( + catalogDatabase(), + session.user.id, + parsed.title, + parsed.category + ); + } catch (error) { + if ( + error instanceof Error && + /no such table: (ExperienceCatalog|CatalogSubmission)/.test(error.message) + ) { + throw new Error('Shared submissions are not set up yet. Your private list is unchanged.'); + } + throw error; + } +} diff --git a/src/lib/catalog-seed.ts b/src/lib/catalog-seed.ts new file mode 100644 index 0000000..4fb56c7 --- /dev/null +++ b/src/lib/catalog-seed.ts @@ -0,0 +1,96 @@ +import { EXPERIENCE_CATEGORIES, EXPERIENCE_ENTRIES, EXPERIENCES_BY_CATEGORY } from './experiences'; +import type { CatalogPage, CatalogQuery } from './catalog-types'; + +export const CATALOG_CATEGORIES = EXPERIENCE_CATEGORIES.map((id) => ({ + id, + label: EXPERIENCES_BY_CATEGORY[id].label, +})); + +export function normalizeCatalogTitle(title: string) { + return title + .normalize('NFKC') + .toLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, ' ') + .trim() + .replace(/\s+/g, ' '); +} + +export function parseCatalogQuery(params: URLSearchParams): CatalogQuery { + const query = (params.get('q') ?? '').trim(); + const category = params.get('category') ?? 'all'; + const kind = params.get('kind') ?? 'all'; + const page = Number(params.get('page') ?? 1); + const pageSize = Number(params.get('pageSize') ?? 20); + if ( + query.length > 200 || + !Number.isSafeInteger(page) || + page < 1 || + page > 1_000_000 || + !Number.isSafeInteger(pageSize) || + pageSize < 1 || + pageSize > 20 || + (category !== 'all' && + !EXPERIENCE_CATEGORIES.includes( + category as CatalogQuery['category'] & (typeof EXPERIENCE_CATEGORIES)[number] + )) || + !['all', 'idea', 'milestone', 'destination'].includes(kind) + ) { + throw new Error('Invalid catalog filters'); + } + return { + query, + category: category as CatalogQuery['category'], + kind: kind as CatalogQuery['kind'], + page, + pageSize, + }; +} + +export function searchCatalogSeed(query: CatalogQuery): CatalogPage { + const term = normalizeCatalogTitle(query.query); + const filtered = EXPERIENCE_ENTRIES.filter( + (entry) => + (query.category === 'all' || entry.category === query.category) && + (query.kind === 'all' || entry.kind === query.kind) && + (!term || normalizeCatalogTitle(`${entry.title} ${entry.description ?? ''}`).includes(term)) + ); + const page = Math.min(query.page, Math.max(1, Math.ceil(filtered.length / query.pageSize))); + return { + items: filtered + .slice((page - 1) * query.pageSize, page * query.pageSize) + .map(({ slug, title, description, emoji, category, kind }) => ({ + slug, + title, + description, + emoji, + category, + kind, + })), + total: EXPERIENCE_ENTRIES.length, + matches: filtered.length, + page, + pageSize: query.pageSize, + source: 'seed', + categories: CATALOG_CATEGORIES, + }; +} + +/** SQL artifact only: rerunning never replaces approved edits or personal data. */ +export function catalogSeedSql() { + const quote = (value: string) => `'${value.replaceAll("'", "''")}'`; + return `${EXPERIENCE_ENTRIES.map( + (entry, order) => + `INSERT INTO ExperienceCatalog (slug,title,normalizedTitle,description,searchText,emoji,category,kind,source,sortOrder) VALUES (${[ + quote(entry.slug), + quote(entry.title), + quote(normalizeCatalogTitle(entry.title)), + entry.description ? quote(entry.description) : 'NULL', + quote(normalizeCatalogTitle(`${entry.title} ${entry.description ?? ''}`)), + quote(entry.emoji), + quote(entry.category), + quote(entry.kind), + "'seed'", + order, + ].join(',')}) ON CONFLICT DO NOTHING;` + ).join('\n')}\n`; +} diff --git a/src/lib/catalog-types.ts b/src/lib/catalog-types.ts new file mode 100644 index 0000000..2f84573 --- /dev/null +++ b/src/lib/catalog-types.ts @@ -0,0 +1,22 @@ +import type { ExperienceCategory, ExperienceEntry, ExperienceKind } from './experiences'; + +export type CatalogIdea = Pick< + ExperienceEntry, + 'slug' | 'title' | 'description' | 'emoji' | 'category' | 'kind' +>; +export type CatalogQuery = { + query: string; + category: ExperienceCategory | 'all'; + kind: ExperienceKind | 'all'; + page: number; + pageSize: number; +}; +export type CatalogPage = { + items: CatalogIdea[]; + total: number; + matches: number; + page: number; + pageSize: number; + source: 'database' | 'seed'; + categories: Array<{ id: ExperienceCategory; label: string }>; +}; diff --git a/src/server/catalog-store.test.ts b/src/server/catalog-store.test.ts new file mode 100644 index 0000000..4a6ed21 --- /dev/null +++ b/src/server/catalog-store.test.ts @@ -0,0 +1,185 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { catalogSeedSql, parseCatalogQuery, searchCatalogSeed } from '~/lib/catalog-seed'; +import { EXPERIENCE_ENTRIES } from '~/lib/experiences'; +import { + reviewStoredCatalogIdea, + searchStoredCatalog, + submitStoredCatalogIdea, + type CatalogDatabase, + type CatalogStatement, +} from './catalog-store'; + +let sqlite: DatabaseSync; +let database: CatalogDatabase; +class Statement implements CatalogStatement { + private values: Array = []; + private readonly sql: string; + constructor(sql: string) { + this.sql = sql; + } + bind(...values: Array) { + this.values = values; + return this; + } + async first(column?: string) { + const row = sqlite.prepare(this.sql).get(...this.values); + return (row ? (column ? row[column] : row) : null) as T | null; + } + async all() { + return { results: sqlite.prepare(this.sql).all(...this.values) as T[] }; + } + async run() { + return sqlite.prepare(this.sql).run(...this.values); + } +} +beforeEach(() => { + sqlite = new DatabaseSync(':memory:'); + sqlite.exec( + "PRAGMA foreign_keys = ON; CREATE TABLE User (id TEXT PRIMARY KEY); INSERT INTO User VALUES ('alice'),('bob'); CREATE TABLE BucketListItem (title TEXT); INSERT INTO BucketListItem VALUES ('A private personal dream');" + ); + sqlite.exec(readFileSync(resolve('migrations/d1/0007_wandering_bloodscream.sql'), 'utf8')); + database = { + prepare: (sql) => new Statement(sql), + async batch(statements) { + sqlite.exec('BEGIN'); + try { + const result = await Promise.all(statements.map((statement) => statement.run())); + sqlite.exec('COMMIT'); + return result; + } catch (error) { + sqlite.exec('ROLLBACK'); + throw error; + } + }, + }; + sqlite.exec(catalogSeedSql()); +}); +afterEach(() => sqlite.close()); +const query = (params = '') => parseCatalogQuery(new URLSearchParams(params)); + +describe('Shared catalog persistence', () => { + it('seeds the canonical corpus idempotently and returns only bounded pages', async () => { + sqlite.exec(catalogSeedSql()); + const page = await searchStoredCatalog(database, query()); + expect(page.total).toBe(EXPERIENCE_ENTRIES.length); + expect(page.items).toHaveLength(20); + expect(page.items.map((item) => item.slug)).toEqual( + searchCatalogSeed(query()).items.map((item) => item.slug) + ); + const last = await searchStoredCatalog(database, query('page=1000')); + expect(last.page).toBe(Math.ceil(page.total / 20)); + expect(last.items).toHaveLength(page.total % 20 || 20); + }); + + it('keeps pending ideas private, then exposes an approved idea across users', async () => { + const submitted = await submitStoredCatalogIdea( + database, + 'alice', + 'Teach a neighbour to weave baskets', + 'creative' + ); + expect(submitted.status).toBe('pending'); + expect((await searchStoredCatalog(database, query('q=weave+baskets'))).matches).toBe(0); + if (!('id' in submitted)) throw new Error('Missing submission'); + await reviewStoredCatalogIdea(database, submitted.id, 'approve', 'operator'); + const page = await searchStoredCatalog(database, query('q=weave+baskets')); + expect(page.items).toEqual([ + expect.objectContaining({ + title: 'Teach a neighbour to weave baskets', + category: 'creative', + kind: 'idea', + }), + ]); + expect(JSON.stringify(page)).not.toMatch(/alice|userId|reviewedBy|private personal/); + expect(sqlite.prepare('SELECT title FROM BucketListItem').get()?.title).toBe( + 'A private personal dream' + ); + expect( + ( + await submitStoredCatalogIdea( + database, + 'bob', + 'Teach a neighbour to weave baskets', + 'creative' + ) + ).status + ).toBe('already-shared'); + }); + + it('rejects without publishing, and prevents another decision on the same submission', async () => { + const submitted = await submitStoredCatalogIdea( + database, + 'alice', + 'Host a neighbourhood weaving afternoon', + 'relationships' + ); + if (!('id' in submitted)) throw new Error('Missing submission'); + await reviewStoredCatalogIdea(database, submitted.id, 'reject', 'operator'); + expect((await searchStoredCatalog(database, query('q=weaving+afternoon'))).matches).toBe(0); + await expect( + reviewStoredCatalogIdea(database, submitted.id, 'approve', 'operator') + ).rejects.toThrow('not pending'); + }); + + it('deduplicates repeat submissions and approvals by different users', async () => { + const first = await submitStoredCatalogIdea( + database, + 'alice', + 'Make a neighbourhood cookbook', + 'creative' + ); + expect( + await submitStoredCatalogIdea(database, 'alice', 'MAKE A NEIGHBOURHOOD COOKBOOK!', 'creative') + ).toEqual(first); + const second = await submitStoredCatalogIdea( + database, + 'bob', + 'Make a neighbourhood cookbook', + 'creative' + ); + if (!('id' in first) || !('id' in second)) throw new Error('Missing submissions'); + await reviewStoredCatalogIdea(database, first.id, 'approve', 'operator'); + await reviewStoredCatalogIdea(database, second.id, 'approve', 'operator'); + expect((await searchStoredCatalog(database, query('q=neighbourhood+cookbook'))).matches).toBe( + 1 + ); + }); + + it('enforces the per-user daily submission bound without blocking another user', async () => { + for (let index = 0; index < 20; index++) + await submitStoredCatalogIdea( + database, + 'alice', + `Unique community suggestion ${index}`, + 'creative' + ); + await expect( + submitStoredCatalogIdea(database, 'alice', 'Another community suggestion', 'creative') + ).rejects.toThrow('Daily submission limit'); + expect( + (await submitStoredCatalogIdea(database, 'bob', 'Another community suggestion', 'creative')) + .status + ).toBe('pending'); + }); + + it('uses bound search parameters, composes filters, and never selects personal lists', async () => { + const page = await searchStoredCatalog( + database, + query('category=travel&kind=destination&pageSize=5') + ); + expect(page.matches).toBe(75); + expect(page.items).toHaveLength(5); + expect((await searchStoredCatalog(database, query('q=%27+OR+1%3D1+--'))).matches).toBe(0); + expect((await searchStoredCatalog(database, query('q=private+personal+dream'))).matches).toBe( + 0 + ); + }); + + it('validates pagination and filters before querying storage', () => { + for (const params of ['page=0', 'pageSize=21', 'page=1.5', 'category=unknown', 'kind=pending']) + expect(() => query(params)).toThrow('Invalid catalog'); + }); +}); diff --git a/src/server/catalog-store.ts b/src/server/catalog-store.ts new file mode 100644 index 0000000..c09a264 --- /dev/null +++ b/src/server/catalog-store.ts @@ -0,0 +1,141 @@ +import { CATALOG_CATEGORIES, normalizeCatalogTitle } from '~/lib/catalog-seed'; +import type { CatalogPage, CatalogQuery } from '~/lib/catalog-types'; +import { EXPERIENCE_ENTRIES } from '~/lib/experiences'; + +export interface CatalogStatement { + bind(...values: Array): CatalogStatement; + first(column?: string): Promise; + all(): Promise<{ results: T[] }>; + run(): Promise; +} +export interface CatalogDatabase { + prepare(sql: string): CatalogStatement; + batch(statements: CatalogStatement[]): Promise; +} + +type CatalogRow = { + slug: string; + title: string; + description: string | null; + emoji: string; + category: CatalogPage['items'][number]['category']; + kind: CatalogPage['items'][number]['kind']; +}; + +/** No public reads of submissions, submitters, notes, or personal-list tables. */ +export async function searchStoredCatalog( + database: CatalogDatabase, + query: CatalogQuery +): Promise { + const conditions: string[] = []; + const bindings: string[] = []; + const term = normalizeCatalogTitle(query.query); + if (term) { + conditions.push('searchText LIKE ?'); + bindings.push(`%${term}%`); + } + if (query.category !== 'all') { + conditions.push('category = ?'); + bindings.push(query.category); + } + if (query.kind !== 'all') { + conditions.push('kind = ?'); + bindings.push(query.kind); + } + const where = conditions.length ? ` WHERE ${conditions.join(' AND ')}` : ''; + const total = + (await database + .prepare('SELECT count(*) AS count FROM ExperienceCatalog') + .first('count')) ?? 0; + const matches = + (await database + .prepare(`SELECT count(*) AS count FROM ExperienceCatalog${where}`) + .bind(...bindings) + .first('count')) ?? 0; + const page = Math.min(query.page, Math.max(1, Math.ceil(matches / query.pageSize))); + const { results } = await database + .prepare( + `SELECT slug,title,description,emoji,category,kind FROM ExperienceCatalog${where} ORDER BY sortOrder,slug LIMIT ? OFFSET ?` + ) + .bind(...bindings, query.pageSize, (page - 1) * query.pageSize) + .all(); + return { + items: results.map(({ description, ...entry }) => ({ + ...entry, + ...(description ? { description } : {}), + })), + total, + matches, + page, + pageSize: query.pageSize, + source: 'database', + categories: CATALOG_CATEGORIES, + }; +} + +export async function submitStoredCatalogIdea( + database: CatalogDatabase, + userId: string, + title: string, + category: string +) { + const normalized = normalizeCatalogTitle(title); + const approved = await database + .prepare('SELECT slug FROM ExperienceCatalog WHERE normalizedTitle = ?') + .bind(normalized) + .first<{ slug: string }>(); + if (approved) return { status: 'already-shared' as const }; + // One atomic insert enforces the daily bound even across Worker instances. + await database + .prepare(`INSERT INTO CatalogSubmission (id,userId,title,normalizedTitle,category) + SELECT ?,?,?,?,? WHERE (SELECT count(*) FROM CatalogSubmission WHERE userId = ? AND createdAt >= unixepoch() - 86400) < 20 + ON CONFLICT(userId,normalizedTitle) DO NOTHING`) + .bind(crypto.randomUUID(), userId, title, normalized, category, userId) + .run(); + const submission = await database + .prepare('SELECT id,status FROM CatalogSubmission WHERE userId = ? AND normalizedTitle = ?') + .bind(userId, normalized) + .first<{ id: string; status: 'pending' | 'approved' | 'rejected' }>(); + if (!submission) throw new Error('Daily submission limit reached. Try again tomorrow.'); + return submission; +} + +/** Operator-only code, deliberately not exposed as an HTTP or server action. */ +export async function reviewStoredCatalogIdea( + database: CatalogDatabase, + id: string, + decision: 'approve' | 'reject', + reviewer: string +) { + if (!reviewer.trim()) throw new Error('A reviewer is required'); + if (decision === 'approve') { + const seedCount = + (await database + .prepare("SELECT count(*) AS count FROM ExperienceCatalog WHERE source = 'seed'") + .first('count')) ?? 0; + if (seedCount < EXPERIENCE_ENTRIES.length) + throw new Error('Seed the shared catalog before approving submissions.'); + } + const submission = await database + .prepare('SELECT status FROM CatalogSubmission WHERE id = ?') + .bind(id) + .first<{ status: string }>(); + if (!submission || submission.status !== 'pending') throw new Error('Submission is not pending'); + const update = database + .prepare( + `UPDATE CatalogSubmission SET status = ?,reviewedBy = ?,reviewedAt = unixepoch() WHERE id = ? AND status = 'pending'` + ) + .bind(decision === 'approve' ? 'approved' : 'rejected', reviewer, id); + if (decision === 'reject') { + await update.run(); + return; + } + await database.batch([ + database + .prepare(`INSERT INTO ExperienceCatalog (slug,title,normalizedTitle,searchText,emoji,category,kind,source) + SELECT ?,title,normalizedTitle,normalizedTitle,'✨',category,'idea','community' FROM CatalogSubmission WHERE id = ? AND status = 'pending' + ON CONFLICT(normalizedTitle) DO NOTHING`) + .bind(`community-${crypto.randomUUID()}`, id), + update, + ]); +} diff --git a/src/server/experience-catalog.test.ts b/src/server/experience-catalog.test.ts new file mode 100644 index 0000000..90fceae --- /dev/null +++ b/src/server/experience-catalog.test.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { parseCatalogQuery } from '~/lib/catalog-seed'; +import { EXPERIENCE_ENTRIES } from '~/lib/experiences'; +import { searchExperienceCatalog } from './experience-catalog'; + +const { context, first, search } = vi.hoisted(() => ({ + context: vi.fn(), + first: vi.fn(), + search: vi.fn(), +})); +vi.mock('@opennextjs/cloudflare', () => ({ getCloudflareContext: context })); +vi.mock('./catalog-store', () => ({ searchStoredCatalog: search })); +beforeEach(() => { + vi.clearAllMocks(); + context.mockReturnValue({ env: { DB: { prepare: () => ({ first }) } } }); +}); + +describe('Catalog rollout reads', () => { + it('uses the shared built-in seed before catalog tables exist', async () => { + first.mockRejectedValue(new Error('D1_ERROR: no such table: ExperienceCatalog')); + const page = await searchExperienceCatalog( + parseCatalogQuery(new URLSearchParams('q=pot&pageSize=5')) + ); + expect(page.source).toBe('seed'); + expect(page.items.length).toBeLessThanOrEqual(5); + expect(search).not.toHaveBeenCalled(); + }); + it('does not hide built-in ideas during a partial seed import', async () => { + first.mockResolvedValue(10); + const page = await searchExperienceCatalog(parseCatalogQuery(new URLSearchParams())); + expect(page.total).toBe(EXPERIENCE_ENTRIES.length); + expect(page.source).toBe('seed'); + }); + it('uses the database after seeding and surfaces unrelated database failures', async () => { + first.mockResolvedValue(EXPERIENCE_ENTRIES.length); + search.mockResolvedValue({ source: 'database' }); + expect(await searchExperienceCatalog(parseCatalogQuery(new URLSearchParams()))).toEqual({ + source: 'database', + }); + first.mockRejectedValue(new Error('Database temporarily unavailable')); + await expect(searchExperienceCatalog(parseCatalogQuery(new URLSearchParams()))).rejects.toThrow( + 'temporarily unavailable' + ); + }); +}); diff --git a/src/server/experience-catalog.ts b/src/server/experience-catalog.ts new file mode 100644 index 0000000..ca56edc --- /dev/null +++ b/src/server/experience-catalog.ts @@ -0,0 +1,26 @@ +import { getCloudflareContext } from '@opennextjs/cloudflare'; +import type { CatalogQuery } from '~/lib/catalog-types'; +import { searchCatalogSeed } from '~/lib/catalog-seed'; +import { EXPERIENCE_ENTRIES } from '~/lib/experiences'; +import { searchStoredCatalog } from './catalog-store'; + +export function catalogDatabase() { + return getCloudflareContext().env.DB; +} + +export async function searchExperienceCatalog(query: CatalogQuery) { + try { + const database = catalogDatabase(); + if (!database) return searchCatalogSeed(query); + const seedCount = await database + .prepare("SELECT count(*) AS count FROM ExperienceCatalog WHERE source = 'seed'") + .first('count'); + // A partially imported seed must not make most built-in ideas disappear. + if (Number(seedCount) < EXPERIENCE_ENTRIES.length) return searchCatalogSeed(query); + return await searchStoredCatalog(database, query); + } catch (error) { + if (error instanceof Error && /no such table: ExperienceCatalog/.test(error.message)) + return searchCatalogSeed(query); + throw error; + } +}