diff --git a/README.md b/README.md index 90d4c07..4cd616b 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ A calm, private gratitude and mood log. No streaks, no gamification—just a sim ## Features +- **User accounts**: Each user has their own private entries and tags - **Quick check-in**: One prompt, optional mood, optional tags. Entry capture takes 10-20 seconds. - **Autosave**: No save button needed. Your entry saves automatically as you type. - **Timeline**: View all entries in reverse chronological order with filters. @@ -22,6 +23,10 @@ docker compose up -d The app will be available at `http://localhost:6124`. +1. Navigate to the app and click "Create one" to register +2. Enter your email, password, and optional name +3. Start logging your gratitude! + ### Manual Setup 1. Install dependencies: @@ -77,10 +82,19 @@ The database is created automatically when you run migrations. In Docker, the `. ### Schema +- **users**: User accounts with email and password hash +- **sessions**: Session tokens for authentication (30-day expiry) - **entries**: Main gratitude entries with date, text, optional mood (1-5), rough day flag, and timestamps -- **tags**: Normalized tag names +- **tags**: Normalized tag names per user - **entry_tags**: Junction table linking entries to tags +## Authentication + +- Session-based authentication with HTTP-only cookies +- Passwords are hashed with bcrypt +- Sessions expire after 30 days +- Each user can only see their own entries and tags + ## Export Navigate to `/export` or use the Export tab to download your data: @@ -88,7 +102,7 @@ Navigate to `/export` or use the Export tab to download your data: - **Markdown**: Human-readable format, grouped by date, includes mood and tags - **JSON**: Full data export with all fields and timestamps -Exports include all entries regardless of filters. +Exports include all entries for the logged-in user. ## Configuration @@ -115,16 +129,39 @@ ports: - **Language**: TypeScript - **Styling**: Tailwind CSS 4 - **Database**: SQLite with Drizzle ORM +- **Authentication**: bcryptjs for password hashing - **Icons**: Lucide React +## API Routes + +### Authentication +- `POST /api/auth/register` - Create new account +- `POST /api/auth/login` - Sign in +- `POST /api/auth/logout` - Sign out +- `GET /api/auth/me` - Get current user + +### Entries +- `GET /api/entries` - List entries (with optional filters) +- `POST /api/entries` - Create or update entry for a date +- `GET /api/entries/[id]` - Get single entry +- `PATCH /api/entries/[id]` - Update entry +- `DELETE /api/entries/[id]` - Delete entry + +### Tags +- `GET /api/tags` - Get recent/search tags + +### Export +- `POST /api/export` - Export all entries (markdown or json) + ## Future Extension Points These features are not implemented but the architecture supports them: -- **Cloud sync**: Add authentication and a sync service to enable cross-device access +- **Cloud sync**: Add a sync service to enable cross-device access - **LLM summaries**: Integrate with an LLM API to generate monthly reflections - **Notifications**: Add push notifications for daily reminders - **Import**: Add an import endpoint to restore from JSON exports +- **OAuth**: Add social login providers ## License diff --git a/drizzle/0000_busy_earthquake.sql b/drizzle/0000_busy_earthquake.sql deleted file mode 100644 index 321bdc8..0000000 --- a/drizzle/0000_busy_earthquake.sql +++ /dev/null @@ -1,25 +0,0 @@ -CREATE TABLE `entries` ( - `id` text PRIMARY KEY NOT NULL, - `date` text NOT NULL, - `text` text NOT NULL, - `mood` integer, - `rough_day` integer DEFAULT 0 NOT NULL, - `created_at` integer NOT NULL, - `updated_at` integer NOT NULL -); ---> statement-breakpoint -CREATE TABLE `entry_tags` ( - `entry_id` text NOT NULL, - `tag_id` text NOT NULL, - PRIMARY KEY(`entry_id`, `tag_id`), - FOREIGN KEY (`entry_id`) REFERENCES `entries`(`id`) ON UPDATE no action ON DELETE cascade, - FOREIGN KEY (`tag_id`) REFERENCES `tags`(`id`) ON UPDATE no action ON DELETE cascade -); ---> statement-breakpoint -CREATE TABLE `tags` ( - `id` text PRIMARY KEY NOT NULL, - `name` text NOT NULL, - `created_at` integer NOT NULL -); ---> statement-breakpoint -CREATE UNIQUE INDEX `tags_name_unique` ON `tags` (`name`); \ No newline at end of file diff --git a/drizzle/0000_flat_captain_marvel.sql b/drizzle/0000_flat_captain_marvel.sql new file mode 100644 index 0000000..e682e74 --- /dev/null +++ b/drizzle/0000_flat_captain_marvel.sql @@ -0,0 +1,45 @@ +CREATE TABLE `entries` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `date` text NOT NULL, + `text` text NOT NULL, + `mood` integer, + `rough_day` integer DEFAULT 0 NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `entry_tags` ( + `entry_id` text NOT NULL, + `tag_id` text NOT NULL, + PRIMARY KEY(`entry_id`, `tag_id`), + FOREIGN KEY (`entry_id`) REFERENCES `entries`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`tag_id`) REFERENCES `tags`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `sessions` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `expires_at` integer NOT NULL, + `created_at` integer NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `tags` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `name` text NOT NULL, + `created_at` integer NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `users` ( + `id` text PRIMARY KEY NOT NULL, + `email` text NOT NULL, + `password_hash` text NOT NULL, + `name` text, + `created_at` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`); \ No newline at end of file diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json index e2d92b1..cb1516d 100644 --- a/drizzle/meta/0000_snapshot.json +++ b/drizzle/meta/0000_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "5116dd80-0292-4345-a403-fd678d148880", + "id": "597c7e60-5f2b-4b3c-befb-4c929ccbc227", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "entries": { @@ -14,6 +14,13 @@ "notNull": true, "autoincrement": false }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, "date": { "name": "date", "type": "text", @@ -59,7 +66,21 @@ } }, "indexes": {}, - "foreignKeys": {}, + "foreignKeys": { + "entries_user_id_users_id_fk": { + "name": "entries_user_id_users_id_fk", + "tableFrom": "entries", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "checkConstraints": {} @@ -123,6 +144,58 @@ "uniqueConstraints": {}, "checkConstraints": {} }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "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": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, "tags": { "name": "tags", "columns": { @@ -133,6 +206,13 @@ "notNull": true, "autoincrement": false }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, "name": { "name": "name", "type": "text", @@ -148,11 +228,70 @@ "autoincrement": false } }, + "indexes": {}, + "foreignKeys": { + "tags_user_id_users_id_fk": { + "name": "tags_user_id_users_id_fk", + "tableFrom": "tags", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, "indexes": { - "tags_name_unique": { - "name": "tags_name_unique", + "users_email_unique": { + "name": "users_email_unique", "columns": [ - "name" + "email" ], "isUnique": true } diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index ca08a71..b0f526d 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "6", - "when": 1769219562573, - "tag": "0000_busy_earthquake", + "when": 1769235451953, + "tag": "0000_flat_captain_marvel", "breakpoints": true } ] diff --git a/package-lock.json b/package-lock.json index 7acdecd..7e27355 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "quietthanks", "version": "0.1.0", "dependencies": { + "bcryptjs": "^3.0.3", "better-sqlite3": "^12.6.2", "drizzle-orm": "^0.45.1", "lucide-react": "^0.562.0", @@ -18,6 +19,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/bcryptjs": "^2.4.6", "@types/better-sqlite3": "^7.6.13", "@types/node": "^20", "@types/react": "^19", @@ -2417,6 +2419,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/better-sqlite3": { "version": "7.6.13", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", @@ -3345,6 +3354,15 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/better-sqlite3": { "version": "12.6.2", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz", diff --git a/package.json b/package.json index 1275626..43081ec 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "db:studio": "drizzle-kit studio" }, "dependencies": { + "bcryptjs": "^3.0.3", "better-sqlite3": "^12.6.2", "drizzle-orm": "^0.45.1", "lucide-react": "^0.562.0", @@ -22,6 +23,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/bcryptjs": "^2.4.6", "@types/better-sqlite3": "^7.6.13", "@types/node": "^20", "@types/react": "^19", diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..0e048f4 --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from "next/server"; +import { loginUser, setSessionCookie } from "@/lib/auth"; + +export async function POST(request: NextRequest) { + try { + const { email, password } = await request.json(); + + if (!email || !password) { + return NextResponse.json( + { error: "Email and password are required" }, + { status: 400 } + ); + } + + const result = await loginUser(email, password); + + if (result.error) { + return NextResponse.json({ error: result.error }, { status: 401 }); + } + + await setSessionCookie(result.sessionId!); + + return NextResponse.json({ user: result.user }); + } catch (error) { + console.error("Login error:", error); + return NextResponse.json({ error: "Login failed" }, { status: 500 }); + } +} diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..e6dad90 --- /dev/null +++ b/src/app/api/auth/logout/route.ts @@ -0,0 +1,12 @@ +import { NextResponse } from "next/server"; +import { clearSessionCookie } from "@/lib/auth"; + +export async function POST() { + try { + await clearSessionCookie(); + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Logout error:", error); + return NextResponse.json({ error: "Logout failed" }, { status: 500 }); + } +} diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts new file mode 100644 index 0000000..ea492ea --- /dev/null +++ b/src/app/api/auth/me/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from "next/server"; +import { getSession } from "@/lib/auth"; + +export async function GET() { + try { + const user = await getSession(); + + if (!user) { + return NextResponse.json({ user: null }); + } + + return NextResponse.json({ user }); + } catch (error) { + console.error("Session check error:", error); + return NextResponse.json({ user: null }); + } +} diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts new file mode 100644 index 0000000..f9d5c0e --- /dev/null +++ b/src/app/api/auth/register/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from "next/server"; +import { registerUser, createSession, setSessionCookie } from "@/lib/auth"; + +export async function POST(request: NextRequest) { + try { + const { email, password, name } = await request.json(); + + if (!email || !password) { + return NextResponse.json( + { error: "Email and password are required" }, + { status: 400 } + ); + } + + if (password.length < 6) { + return NextResponse.json( + { error: "Password must be at least 6 characters" }, + { status: 400 } + ); + } + + const result = await registerUser(email, password, name); + + if (result.error) { + return NextResponse.json({ error: result.error }, { status: 400 }); + } + + // Create session and set cookie + const sessionId = await createSession(result.user!.id); + await setSessionCookie(sessionId); + + return NextResponse.json({ user: result.user }); + } catch (error) { + console.error("Registration error:", error); + return NextResponse.json( + { error: "Registration failed" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/entries/[id]/route.ts b/src/app/api/entries/[id]/route.ts index d251de2..8f9004a 100644 --- a/src/app/api/entries/[id]/route.ts +++ b/src/app/api/entries/[id]/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { db, schema } from "@/lib/db"; -import { eq } from "drizzle-orm"; +import { eq, and } from "drizzle-orm"; +import { getSession } from "@/lib/auth"; import type { UpdateEntryRequest, EntryWithTags } from "@/lib/types"; import { v4 as uuidv4 } from "uuid"; @@ -10,13 +11,23 @@ interface RouteParams { // GET /api/entries/[id] - Get single entry export async function GET(_request: NextRequest, { params }: RouteParams) { + const user = await getSession(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const { id } = await params; try { const entry = await db .select() .from(schema.entries) - .where(eq(schema.entries.id, id)) + .where( + and( + eq(schema.entries.id, id), + eq(schema.entries.userId, user.id) + ) + ) .limit(1); if (entry.length === 0) { @@ -43,6 +54,11 @@ export async function GET(_request: NextRequest, { params }: RouteParams) { // PATCH /api/entries/[id] - Update entry export async function PATCH(request: NextRequest, { params }: RouteParams) { + const user = await getSession(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const { id } = await params; try { @@ -52,7 +68,12 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { const existing = await db .select() .from(schema.entries) - .where(eq(schema.entries.id, id)) + .where( + and( + eq(schema.entries.id, id), + eq(schema.entries.userId, user.id) + ) + ) .limit(1); if (existing.length === 0) { @@ -82,7 +103,12 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { let tag = await db .select() .from(schema.tags) - .where(eq(schema.tags.name, normalizedName)) + .where( + and( + eq(schema.tags.userId, user.id), + eq(schema.tags.name, normalizedName) + ) + ) .limit(1); let tagId: string; @@ -90,6 +116,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { tagId = uuidv4(); await db.insert(schema.tags).values({ id: tagId, + userId: user.id, name: normalizedName, createdAt: now, }); @@ -131,13 +158,23 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { // DELETE /api/entries/[id] - Delete entry export async function DELETE(_request: NextRequest, { params }: RouteParams) { + const user = await getSession(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const { id } = await params; try { const existing = await db .select() .from(schema.entries) - .where(eq(schema.entries.id, id)) + .where( + and( + eq(schema.entries.id, id), + eq(schema.entries.userId, user.id) + ) + ) .limit(1); if (existing.length === 0) { diff --git a/src/app/api/entries/route.ts b/src/app/api/entries/route.ts index 45f2dba..050d48e 100644 --- a/src/app/api/entries/route.ts +++ b/src/app/api/entries/route.ts @@ -2,10 +2,16 @@ import { NextRequest, NextResponse } from "next/server"; import { db, schema } from "@/lib/db"; import { eq, desc, and, inArray, gte } from "drizzle-orm"; import { v4 as uuidv4 } from "uuid"; +import { getSession } from "@/lib/auth"; import type { CreateEntryRequest, EntryWithTags } from "@/lib/types"; // GET /api/entries - List entries with optional filters export async function GET(request: NextRequest) { + const user = await getSession(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const { searchParams } = new URL(request.url); const moods = searchParams.get("moods")?.split(",").map(Number).filter(Boolean); const tagId = searchParams.get("tagId"); @@ -13,8 +19,8 @@ export async function GET(request: NextRequest) { const since = searchParams.get("since"); // YYYY-MM-DD try { - // Build conditions - const conditions = []; + // Build conditions - always filter by userId + const conditions = [eq(schema.entries.userId, user.id)]; if (moods && moods.length > 0) { conditions.push(inArray(schema.entries.mood, moods)); } @@ -31,7 +37,7 @@ export async function GET(request: NextRequest) { const entries = await db .select() .from(schema.entries) - .where(conditions.length > 0 ? and(...conditions) : undefined) + .where(and(...conditions)) .orderBy(desc(schema.entries.date), desc(schema.entries.createdAt)); // Get tags for each entry @@ -62,6 +68,11 @@ export async function GET(request: NextRequest) { // POST /api/entries - Create or update today's entry export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + try { const body: CreateEntryRequest = await request.json(); const { date, text, mood, roughDay, tagNames } = body; @@ -72,11 +83,16 @@ export async function POST(request: NextRequest) { const now = Date.now(); - // Check if entry exists for this date + // Check if entry exists for this date for this user const existing = await db .select() .from(schema.entries) - .where(eq(schema.entries.date, date)) + .where( + and( + eq(schema.entries.userId, user.id), + eq(schema.entries.date, date) + ) + ) .limit(1); let entryId: string; @@ -98,6 +114,7 @@ export async function POST(request: NextRequest) { entryId = uuidv4(); await db.insert(schema.entries).values({ id: entryId, + userId: user.id, date, text, mood: mood ?? null, @@ -117,11 +134,16 @@ export async function POST(request: NextRequest) { const normalizedName = name.toLowerCase().trim(); if (!normalizedName) continue; - // Find or create tag + // Find or create tag for this user let tag = await db .select() .from(schema.tags) - .where(eq(schema.tags.name, normalizedName)) + .where( + and( + eq(schema.tags.userId, user.id), + eq(schema.tags.name, normalizedName) + ) + ) .limit(1); let tagId: string; @@ -129,6 +151,7 @@ export async function POST(request: NextRequest) { tagId = uuidv4(); await db.insert(schema.tags).values({ id: tagId, + userId: user.id, name: normalizedName, createdAt: now, }); diff --git a/src/app/api/export/route.ts b/src/app/api/export/route.ts index 3c87bcd..f41df68 100644 --- a/src/app/api/export/route.ts +++ b/src/app/api/export/route.ts @@ -1,20 +1,27 @@ import { NextRequest, NextResponse } from "next/server"; import { db, schema } from "@/lib/db"; -import { eq, desc } from "drizzle-orm"; +import { eq, desc, and } from "drizzle-orm"; +import { getSession } from "@/lib/auth"; import type { EntryWithTags, ExportFormat } from "@/lib/types"; import { MOOD_LABELS, APP_NAME } from "@/lib/constants"; import { formatDateLong } from "@/lib/utils/date"; -// POST /api/export - Export all entries +// POST /api/export - Export all entries for current user export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + try { const body = await request.json(); const format: ExportFormat = body.format || "markdown"; - // Fetch all entries with tags + // Fetch all entries with tags for this user const entries = await db .select() .from(schema.entries) + .where(eq(schema.entries.userId, user.id)) .orderBy(desc(schema.entries.date)); const entriesWithTags: EntryWithTags[] = []; @@ -36,6 +43,7 @@ export async function POST(request: NextRequest) { { exportedAt: new Date().toISOString(), appName: APP_NAME, + user: { email: user.email, name: user.name }, entries: entriesWithTags, }, null, @@ -52,7 +60,8 @@ export async function POST(request: NextRequest) { // Markdown export let markdown = `# ${APP_NAME} Export\n\n`; - markdown += `Exported on ${new Date().toLocaleDateString("en-US", { dateStyle: "long" })}\n\n`; + markdown += `Exported on ${new Date().toLocaleDateString("en-US", { dateStyle: "long" })}\n`; + markdown += `Account: ${user.email}\n\n`; markdown += `---\n\n`; // Group by date diff --git a/src/app/api/tags/route.ts b/src/app/api/tags/route.ts index ff4a57e..6269784 100644 --- a/src/app/api/tags/route.ts +++ b/src/app/api/tags/route.ts @@ -1,26 +1,37 @@ import { NextRequest, NextResponse } from "next/server"; import { db, schema } from "@/lib/db"; -import { desc, like, sql } from "drizzle-orm"; +import { desc, like, sql, eq, and } from "drizzle-orm"; +import { getSession } from "@/lib/auth"; // GET /api/tags - Get tags (recent or search) export async function GET(request: NextRequest) { + const user = await getSession(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const { searchParams } = new URL(request.url); const search = searchParams.get("search"); const limit = parseInt(searchParams.get("limit") || "10", 10); try { if (search) { - // Search tags by name + // Search tags by name for this user const tags = await db .select() .from(schema.tags) - .where(like(schema.tags.name, `%${search.toLowerCase()}%`)) + .where( + and( + eq(schema.tags.userId, user.id), + like(schema.tags.name, `%${search.toLowerCase()}%`) + ) + ) .limit(limit); return NextResponse.json(tags); } - // Get most recently used tags (by entry count) + // Get most recently used tags (by entry count) for this user const tagsWithCount = await db .select({ id: schema.tags.id, @@ -30,6 +41,7 @@ export async function GET(request: NextRequest) { }) .from(schema.tags) .leftJoin(schema.entryTags, sql`${schema.tags.id} = ${schema.entryTags.tagId}`) + .where(eq(schema.tags.userId, user.id)) .groupBy(schema.tags.id) .orderBy(desc(sql`use_count`), desc(schema.tags.createdAt)) .limit(limit); diff --git a/src/app/entry/[id]/page.tsx b/src/app/entry/[id]/page.tsx index ea1d240..526e355 100644 --- a/src/app/entry/[id]/page.tsx +++ b/src/app/entry/[id]/page.tsx @@ -2,6 +2,7 @@ import { use, useState, useEffect, useCallback } from "react"; import { useRouter } from "next/navigation"; +import { AppShell } from "@/components/AppShell"; import { EntryForm } from "@/components/EntryForm"; import { ToastContainer } from "@/components/Toast"; import { useToast } from "@/hooks/useToast"; @@ -78,7 +79,7 @@ export default function EntryPage({ params }: EntryPageProps) { }, [entry, id, router, showToast]); return ( -
Export
@@ -97,6 +98,6 @@ export default function ExportPage() {Sign in to your account
++ Don't have an account?{" "} + + Create one + +
+{formatDate(today)}
+{formatDate(today)}
+Create your account
++ Already have an account?{" "} + + Sign in + +
+Settings
{user.email}
+ {user.name &&{user.name}
} + +A calm, private gratitude log.
Timeline
@@ -88,6 +89,6 @@ export default function TimelinePage() { )} > )} -