diff --git a/drizzle/0001_chunky_the_liberteens.sql b/drizzle/0001_chunky_the_liberteens.sql new file mode 100644 index 0000000..17f5196 --- /dev/null +++ b/drizzle/0001_chunky_the_liberteens.sql @@ -0,0 +1 @@ +ALTER TABLE `users` ADD `is_admin` integer DEFAULT 0 NOT NULL; \ No newline at end of file diff --git a/drizzle/0002_windy_rawhide_kid.sql b/drizzle/0002_windy_rawhide_kid.sql new file mode 100644 index 0000000..dbd99ae --- /dev/null +++ b/drizzle/0002_windy_rawhide_kid.sql @@ -0,0 +1,3 @@ +ALTER TABLE `users` ADD `reminder_enabled` integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE `users` ADD `reminder_time` text;--> statement-breakpoint +ALTER TABLE `users` ADD `llm_api_key` text; \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..f66f0cc --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,323 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "61aec4e4-4f21-46ad-902e-0082516daaee", + "prevId": "597c7e60-5f2b-4b3c-befb-4c929ccbc227", + "tables": { + "entries": { + "name": "entries", + "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 + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mood": { + "name": "mood", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rough_day": { + "name": "rough_day", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "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": { + "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": {} + }, + "entry_tags": { + "name": "entry_tags", + "columns": { + "entry_id": { + "name": "entry_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "entry_tags_entry_id_entries_id_fk": { + "name": "entry_tags_entry_id_entries_id_fk", + "tableFrom": "entry_tags", + "tableTo": "entries", + "columnsFrom": [ + "entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entry_tags_tag_id_tags_id_fk": { + "name": "entry_tags_tag_id_tags_id_fk", + "tableFrom": "entry_tags", + "tableTo": "tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "entry_tags_entry_id_tag_id_pk": { + "columns": [ + "entry_id", + "tag_id" + ], + "name": "entry_tags_entry_id_tag_id_pk" + } + }, + "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": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "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 + }, + "is_admin": { + "name": "is_admin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..0361a3d --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,345 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "a542a0cf-2a43-4e4d-a8b0-e6b14b8b007e", + "prevId": "61aec4e4-4f21-46ad-902e-0082516daaee", + "tables": { + "entries": { + "name": "entries", + "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 + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mood": { + "name": "mood", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rough_day": { + "name": "rough_day", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "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": { + "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": {} + }, + "entry_tags": { + "name": "entry_tags", + "columns": { + "entry_id": { + "name": "entry_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "entry_tags_entry_id_entries_id_fk": { + "name": "entry_tags_entry_id_entries_id_fk", + "tableFrom": "entry_tags", + "tableTo": "entries", + "columnsFrom": [ + "entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entry_tags_tag_id_tags_id_fk": { + "name": "entry_tags_tag_id_tags_id_fk", + "tableFrom": "entry_tags", + "tableTo": "tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "entry_tags_entry_id_tag_id_pk": { + "columns": [ + "entry_id", + "tag_id" + ], + "name": "entry_tags_entry_id_tag_id_pk" + } + }, + "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": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "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 + }, + "is_admin": { + "name": "is_admin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "reminder_enabled": { + "name": "reminder_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "reminder_time": { + "name": "reminder_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "llm_api_key": { + "name": "llm_api_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index b0f526d..a4f909e 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,20 @@ "when": 1769235451953, "tag": "0000_flat_captain_marvel", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1769255954092, + "tag": "0001_chunky_the_liberteens", + "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1769256216333, + "tag": "0002_windy_rawhide_kid", + "breakpoints": true } ] } \ No newline at end of file diff --git a/next.config.ts b/next.config.ts index 033379b..68a6c64 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,7 +2,6 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { output: "standalone", - assetPrefix: "/quietthanks", }; export default nextConfig; diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx new file mode 100644 index 0000000..4b2a396 --- /dev/null +++ b/src/app/admin/page.tsx @@ -0,0 +1,423 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { AppShell } from "@/components/AppShell"; +import { useAuth } from "@/components/AuthProvider"; +import { APP_NAME } from "@/lib/constants"; +import { + ArrowLeft, + Loader2, + Plus, + Trash2, + Key, + Shield, + ShieldOff, + User, + X, +} from "lucide-react"; +import Link from "next/link"; + +interface UserData { + id: string; + email: string; + name: string | null; + isAdmin: boolean; + createdAt: number; +} + +export default function AdminPage() { + const router = useRouter(); + const { user } = useAuth(); + const [users, setUsers] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // Modal states + const [showAddUser, setShowAddUser] = useState(false); + const [showResetPassword, setShowResetPassword] = useState(null); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(null); + + // Form states + const [newEmail, setNewEmail] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [newName, setNewName] = useState(""); + const [newIsAdmin, setNewIsAdmin] = useState(false); + const [resetPassword, setResetPassword] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + const loadUsers = useCallback(async () => { + try { + const res = await fetch("/api/admin/users"); + if (res.status === 403) { + router.push("/"); + return; + } + if (res.ok) { + setUsers(await res.json()); + } else { + setError("Failed to load users"); + } + } catch { + setError("Failed to load users"); + } finally { + setIsLoading(false); + } + }, [router]); + + useEffect(() => { + if (user && !user.isAdmin) { + router.push("/"); + return; + } + loadUsers(); + }, [user, router, loadUsers]); + + const handleAddUser = async (e: React.FormEvent) => { + e.preventDefault(); + setIsSubmitting(true); + setError(null); + + try { + const res = await fetch("/api/admin/users", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: newEmail, + password: newPassword, + name: newName, + isAdmin: newIsAdmin, + }), + }); + + if (res.ok) { + setShowAddUser(false); + setNewEmail(""); + setNewPassword(""); + setNewName(""); + setNewIsAdmin(false); + loadUsers(); + } else { + const data = await res.json(); + setError(data.error || "Failed to create user"); + } + } catch { + setError("Failed to create user"); + } finally { + setIsSubmitting(false); + } + }; + + const handleResetPassword = async (e: React.FormEvent) => { + e.preventDefault(); + if (!showResetPassword) return; + + setIsSubmitting(true); + setError(null); + + try { + const res = await fetch(`/api/admin/users/${showResetPassword.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password: resetPassword }), + }); + + if (res.ok) { + setShowResetPassword(null); + setResetPassword(""); + } else { + const data = await res.json(); + setError(data.error || "Failed to reset password"); + } + } catch { + setError("Failed to reset password"); + } finally { + setIsSubmitting(false); + } + }; + + const handleDeleteUser = async () => { + if (!showDeleteConfirm) return; + + setIsSubmitting(true); + setError(null); + + try { + const res = await fetch(`/api/admin/users/${showDeleteConfirm.id}`, { + method: "DELETE", + }); + + if (res.ok) { + setShowDeleteConfirm(null); + loadUsers(); + } else { + const data = await res.json(); + setError(data.error || "Failed to delete user"); + } + } catch { + setError("Failed to delete user"); + } finally { + setIsSubmitting(false); + } + }; + + const toggleAdmin = async (targetUser: UserData) => { + try { + const res = await fetch(`/api/admin/users/${targetUser.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ isAdmin: !targetUser.isAdmin }), + }); + + if (res.ok) { + loadUsers(); + } else { + const data = await res.json(); + setError(data.error || "Failed to update user"); + } + } catch { + setError("Failed to update user"); + } + }; + + if (!user?.isAdmin) { + return null; + } + + return ( + +
+ + + +
+

{APP_NAME}

+

User Management

+
+
+ + {error && ( +
+ {error} +
+ )} + +
+ +
+ + {isLoading ? ( +
+ +
+ ) : ( +
+ {users.map((u) => ( +
+
+ +
+
+

{u.email}

+

+ {u.name || "No name"} • {u.isAdmin && "Admin • "} + {new Date(u.createdAt).toLocaleDateString()} +

+
+
+ + + +
+
+ ))} +
+ )} + + {/* Add User Modal */} + {showAddUser && ( +
+
+
+

Add User

+ +
+
+
+ + setNewEmail(e.target.value)} + required + className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:border-muted" + /> +
+
+ + setNewPassword(e.target.value)} + required + minLength={6} + className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:border-muted" + /> +
+
+ + setNewName(e.target.value)} + className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:border-muted" + /> +
+
+ +
+
+ + +
+
+
+
+ )} + + {/* Reset Password Modal */} + {showResetPassword && ( +
+
+
+

Reset Password

+ +
+

+ Reset password for {showResetPassword.email} +

+
+
+ + setResetPassword(e.target.value)} + required + minLength={6} + className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:border-muted" + /> +
+
+ + +
+
+
+
+ )} + + {/* Delete Confirmation Modal */} + {showDeleteConfirm && ( +
+
+

Delete User?

+

+ This will permanently delete {showDeleteConfirm.email} and + all their entries. This cannot be undone. +

+
+ + +
+
+
+ )} +
+ ); +} diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts new file mode 100644 index 0000000..88ede4f --- /dev/null +++ b/src/app/api/admin/users/[id]/route.ts @@ -0,0 +1,110 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db, schema } from "@/lib/db"; +import { getSession, hashPassword } from "@/lib/auth"; +import { eq } from "drizzle-orm"; + +interface RouteParams { + params: Promise<{ id: string }>; +} + +// DELETE /api/admin/users/[id] - Delete a user (admin only) +export async function DELETE(request: NextRequest, { params }: RouteParams) { + const user = await getSession(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + if (!user.isAdmin) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const { id } = await params; + + // Prevent self-deletion + if (id === user.id) { + return NextResponse.json( + { error: "Cannot delete your own account" }, + { status: 400 } + ); + } + + try { + await db.delete(schema.users).where(eq(schema.users.id, id)); + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Failed to delete user:", error); + return NextResponse.json({ error: "Failed to delete user" }, { status: 500 }); + } +} + +// PATCH /api/admin/users/[id] - Update user (reset password, toggle admin) +export async function PATCH(request: NextRequest, { params }: RouteParams) { + const user = await getSession(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + if (!user.isAdmin) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const { id } = await params; + const body = await request.json(); + + try { + const updates: Record = {}; + + // Reset password + if (body.password) { + updates.passwordHash = await hashPassword(body.password); + } + + // Update admin status (prevent self-demotion) + if (typeof body.isAdmin === "boolean") { + if (id === user.id && !body.isAdmin) { + return NextResponse.json( + { error: "Cannot remove your own admin status" }, + { status: 400 } + ); + } + updates.isAdmin = body.isAdmin ? 1 : 0; + } + + // Update name + if (typeof body.name === "string") { + updates.name = body.name || null; + } + + if (Object.keys(updates).length === 0) { + return NextResponse.json({ error: "No updates provided" }, { status: 400 }); + } + + await db + .update(schema.users) + .set(updates) + .where(eq(schema.users.id, id)); + + // Fetch updated user + const updated = await db + .select({ + id: schema.users.id, + email: schema.users.email, + name: schema.users.name, + isAdmin: schema.users.isAdmin, + createdAt: schema.users.createdAt, + }) + .from(schema.users) + .where(eq(schema.users.id, id)) + .limit(1); + + if (updated.length === 0) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + return NextResponse.json({ + ...updated[0], + isAdmin: updated[0].isAdmin === 1, + }); + } catch (error) { + console.error("Failed to update user:", error); + return NextResponse.json({ error: "Failed to update user" }, { status: 500 }); + } +} diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts new file mode 100644 index 0000000..90ad047 --- /dev/null +++ b/src/app/api/admin/users/route.ts @@ -0,0 +1,99 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db, schema } from "@/lib/db"; +import { getSession, hashPassword } from "@/lib/auth"; +import { v4 as uuidv4 } from "uuid"; +import { desc, eq } from "drizzle-orm"; + +// GET /api/admin/users - List all users (admin only) +export async function GET() { + const user = await getSession(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + if (!user.isAdmin) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + try { + const users = await db + .select({ + id: schema.users.id, + email: schema.users.email, + name: schema.users.name, + isAdmin: schema.users.isAdmin, + createdAt: schema.users.createdAt, + }) + .from(schema.users) + .orderBy(desc(schema.users.createdAt)); + + return NextResponse.json( + users.map((u) => ({ + ...u, + isAdmin: u.isAdmin === 1, + })) + ); + } catch (error) { + console.error("Failed to fetch users:", error); + return NextResponse.json({ error: "Failed to fetch users" }, { status: 500 }); + } +} + +// POST /api/admin/users - Create a new user (admin only) +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + if (!user.isAdmin) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + try { + const { email, password, name, isAdmin } = await request.json(); + + if (!email || !password) { + return NextResponse.json( + { error: "Email and password are required" }, + { status: 400 } + ); + } + + // Check if email already exists + const existing = await db + .select() + .from(schema.users) + .where(eq(schema.users.email, email.toLowerCase())) + .limit(1); + + if (existing.length > 0) { + return NextResponse.json( + { error: "Email already registered" }, + { status: 400 } + ); + } + + const userId = uuidv4(); + const passwordHash = await hashPassword(password); + const now = Date.now(); + + await db.insert(schema.users).values({ + id: userId, + email: email.toLowerCase(), + passwordHash, + name: name || null, + isAdmin: isAdmin ? 1 : 0, + createdAt: now, + }); + + return NextResponse.json({ + id: userId, + email: email.toLowerCase(), + name: name || null, + isAdmin: Boolean(isAdmin), + createdAt: now, + }); + } catch (error) { + console.error("Failed to create user:", error); + return NextResponse.json({ error: "Failed to create user" }, { status: 500 }); + } +} diff --git a/src/app/api/entries/route.ts b/src/app/api/entries/route.ts index 050d48e..aefd34f 100644 --- a/src/app/api/entries/route.ts +++ b/src/app/api/entries/route.ts @@ -66,7 +66,7 @@ export async function GET(request: NextRequest) { } } -// POST /api/entries - Create or update today's entry +// POST /api/entries - Create a new entry or update existing by id export async function POST(request: NextRequest) { const user = await getSession(); if (!user) { @@ -75,31 +75,33 @@ export async function POST(request: NextRequest) { try { const body: CreateEntryRequest = await request.json(); - const { date, text, mood, roughDay, tagNames } = body; + const { id: existingId, date, text, mood, roughDay, tagNames } = body; if (!date || !text) { return NextResponse.json({ error: "Date and text are required" }, { status: 400 }); } const now = Date.now(); - - // Check if entry exists for this date for this user - const existing = await db - .select() - .from(schema.entries) - .where( - and( - eq(schema.entries.userId, user.id), - eq(schema.entries.date, date) - ) - ) - .limit(1); - let entryId: string; - if (existing.length > 0) { - // Update existing entry - entryId = existing[0].id; + if (existingId) { + // Update existing entry - verify ownership + const existing = await db + .select() + .from(schema.entries) + .where( + and( + eq(schema.entries.id, existingId), + eq(schema.entries.userId, user.id) + ) + ) + .limit(1); + + if (existing.length === 0) { + return NextResponse.json({ error: "Entry not found" }, { status: 404 }); + } + + entryId = existingId; await db .update(schema.entries) .set({ diff --git a/src/app/api/reflections/route.ts b/src/app/api/reflections/route.ts new file mode 100644 index 0000000..b6e4d5c --- /dev/null +++ b/src/app/api/reflections/route.ts @@ -0,0 +1,154 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db, schema } from "@/lib/db"; +import { getSession } from "@/lib/auth"; +import { eq, and, gte, lte, desc } from "drizzle-orm"; + +// POST /api/reflections - Generate an LLM reflection for a time period +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const { period } = await request.json(); // "week" | "month" | "year" + + // Get user's API key + const users = await db + .select({ llmApiKey: schema.users.llmApiKey }) + .from(schema.users) + .where(eq(schema.users.id, user.id)) + .limit(1); + + if (!users[0]?.llmApiKey) { + return NextResponse.json( + { error: "Please add your Claude API key in settings" }, + { status: 400 } + ); + } + + // Calculate date range + const now = new Date(); + let startDate: Date; + + switch (period) { + case "week": + startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + break; + case "month": + startDate = new Date(now.getFullYear(), now.getMonth() - 1, now.getDate()); + break; + case "year": + startDate = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate()); + break; + default: + startDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + } + + const startDateStr = startDate.toISOString().split("T")[0]; + const endDateStr = now.toISOString().split("T")[0]; + + // Fetch entries for the period + const entries = await db + .select() + .from(schema.entries) + .where( + and( + eq(schema.entries.userId, user.id), + gte(schema.entries.date, startDateStr), + lte(schema.entries.date, endDateStr) + ) + ) + .orderBy(desc(schema.entries.date)); + + if (entries.length === 0) { + return NextResponse.json({ + reflection: "No entries found for this period. Start logging your gratitude to see reflections!", + period, + entryCount: 0, + }); + } + + // Get tags for entries + const entriesWithTags = await Promise.all( + entries.map(async (entry) => { + const tagRows = await db + .select({ name: schema.tags.name }) + .from(schema.entryTags) + .innerJoin(schema.tags, eq(schema.entryTags.tagId, schema.tags.id)) + .where(eq(schema.entryTags.entryId, entry.id)); + return { + ...entry, + tags: tagRows.map((t) => t.name), + }; + }) + ); + + // Format entries for the prompt + const formattedEntries = entriesWithTags.map((e) => { + const moodLabel = e.mood ? `Mood: ${e.mood}/5` : ""; + const roughDay = e.roughDay ? "(rough day)" : ""; + const tags = e.tags.length > 0 ? `[${e.tags.join(", ")}]` : ""; + return `${e.date} ${moodLabel} ${roughDay} ${tags}\n${e.text}`; + }).join("\n\n---\n\n"); + + const periodLabel = period === "week" ? "past week" : period === "month" ? "past month" : "past year"; + + // Call Claude API + const response = await fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": users[0].llmApiKey, + "anthropic-version": "2023-06-01", + }, + body: JSON.stringify({ + model: "claude-sonnet-4-20250514", + max_tokens: 1024, + messages: [ + { + role: "user", + content: `You are a thoughtful, warm companion helping someone reflect on their gratitude journal. Below are their entries from the ${periodLabel}. + +Please write a brief, heartfelt reflection (2-3 paragraphs) that: +1. Identifies patterns or themes in what they're grateful for +2. Acknowledges any difficult days with compassion +3. Offers a gentle observation or insight +4. Ends with an encouraging thought + +Keep the tone calm, supportive, and personal - like a wise friend. Don't be overly cheerful or use excessive positivity. Be genuine. + +Here are the entries: + +${formattedEntries}`, + }, + ], + }), + }); + + if (!response.ok) { + const error = await response.text(); + console.error("Claude API error:", error); + return NextResponse.json( + { error: "Failed to generate reflection. Please check your API key." }, + { status: 500 } + ); + } + + const data = await response.json(); + const reflection = data.content[0]?.text || "Unable to generate reflection."; + + return NextResponse.json({ + reflection, + period, + entryCount: entries.length, + dateRange: { start: startDateStr, end: endDateStr }, + }); + } catch (error) { + console.error("Failed to generate reflection:", error); + return NextResponse.json( + { error: "Failed to generate reflection" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts new file mode 100644 index 0000000..7239a27 --- /dev/null +++ b/src/app/api/settings/route.ts @@ -0,0 +1,77 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db, schema } from "@/lib/db"; +import { getSession } from "@/lib/auth"; +import { eq } from "drizzle-orm"; + +// GET /api/settings - Get current user settings +export async function GET() { + const user = await getSession(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const users = await db + .select({ + reminderEnabled: schema.users.reminderEnabled, + reminderTime: schema.users.reminderTime, + hasLlmKey: schema.users.llmApiKey, + }) + .from(schema.users) + .where(eq(schema.users.id, user.id)) + .limit(1); + + if (users.length === 0) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + return NextResponse.json({ + reminderEnabled: users[0].reminderEnabled === 1, + reminderTime: users[0].reminderTime || "20:00", + hasLlmKey: Boolean(users[0].hasLlmKey), + }); + } catch (error) { + console.error("Failed to fetch settings:", error); + return NextResponse.json({ error: "Failed to fetch settings" }, { status: 500 }); + } +} + +// PATCH /api/settings - Update user settings +export async function PATCH(request: NextRequest) { + const user = await getSession(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const body = await request.json(); + const updates: Record = {}; + + if (typeof body.reminderEnabled === "boolean") { + updates.reminderEnabled = body.reminderEnabled ? 1 : 0; + } + + if (typeof body.reminderTime === "string") { + updates.reminderTime = body.reminderTime; + } + + if (typeof body.llmApiKey === "string") { + // Store the API key (in production, encrypt this) + updates.llmApiKey = body.llmApiKey || null; + } + + if (Object.keys(updates).length === 0) { + return NextResponse.json({ error: "No updates provided" }, { status: 400 }); + } + + await db + .update(schema.users) + .set(updates) + .where(eq(schema.users.id, user.id)); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Failed to update settings:", error); + return NextResponse.json({ error: "Failed to update settings" }, { status: 500 }); + } +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 224c685..c597231 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -31,7 +31,7 @@ export default function HomePage() { )} - +
("month"); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [reflection, setReflection] = useState(null); + + const generateReflection = async () => { + setIsLoading(true); + setError(null); + + try { + const res = await fetch("/api/reflections", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ period }), + }); + + const data = await res.json(); + + if (!res.ok) { + setError(data.error || "Failed to generate reflection"); + return; + } + + setReflection(data); + } catch { + setError("Failed to generate reflection"); + } finally { + setIsLoading(false); + } + }; + + const periodLabels = { + week: "Past Week", + month: "Past Month", + year: "Past Year", + }; + + return ( + +
+ + + +
+

{APP_NAME}

+

AI Reflections

+
+
+ +
+

+ Generate thoughtful reflections on your gratitude entries using AI. + Choose a time period and let Claude help you find patterns and insights. +

+ + {/* Period selector */} +
+ {(["week", "month", "year"] as const).map((p) => ( + + ))} +
+ + {/* Generate button */} + + + {/* Error */} + {error && ( +
+ {error} +
+ )} + + {/* Reflection result */} + {reflection && ( +
+
+ + + {periodLabels[reflection.period as keyof typeof periodLabels]} •{" "} + {reflection.entryCount} entries + + {reflection.dateRange && ( + + ({reflection.dateRange.start} to {reflection.dateRange.end}) + + )} +
+
+ {reflection.reflection.split("\n\n").map((paragraph, i) => ( +

+ {paragraph} +

+ ))} +
+
+ )} + + {/* Info */} +
+ Reflections are generated using Claude and are not stored. +
+ Each generation uses your API key. +
+
+
+ ); +} diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 54d5854..8d4e77a 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -1,12 +1,133 @@ "use client"; +import { useState, useEffect } from "react"; import { AppShell } from "@/components/AppShell"; import { useAuth } from "@/components/AuthProvider"; import { APP_NAME } from "@/lib/constants"; -import { LogOut } from "lucide-react"; +import { LogOut, Users, Bell, Sparkles, Loader2, Check, Key } from "lucide-react"; +import Link from "next/link"; + +interface UserSettings { + reminderEnabled: boolean; + reminderTime: string; + hasLlmKey: boolean; +} export default function SettingsPage() { const { user, logout } = useAuth(); + const [settings, setSettings] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [showApiKeyInput, setShowApiKeyInput] = useState(false); + const [apiKey, setApiKey] = useState(""); + const [notificationPermission, setNotificationPermission] = useState("default"); + + useEffect(() => { + async function loadSettings() { + try { + const res = await fetch("/api/settings"); + if (res.ok) { + setSettings(await res.json()); + } + } catch (error) { + console.error("Failed to load settings:", error); + } finally { + setIsLoading(false); + } + } + loadSettings(); + + // Check notification permission + if ("Notification" in window) { + setNotificationPermission(Notification.permission); + } + }, []); + + const updateSetting = async (key: string, value: unknown) => { + setIsSaving(true); + try { + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ [key]: value }), + }); + if (res.ok) { + setSettings((prev) => prev ? { ...prev, [key]: value } : null); + } + } catch (error) { + console.error("Failed to update setting:", error); + } finally { + setIsSaving(false); + } + }; + + const requestNotificationPermission = async () => { + if ("Notification" in window) { + const permission = await Notification.requestPermission(); + setNotificationPermission(permission); + if (permission === "granted") { + updateSetting("reminderEnabled", true); + } + } + }; + + const toggleReminder = async () => { + if (!settings) return; + + if (!settings.reminderEnabled) { + // Enabling - need permission first + if (notificationPermission !== "granted") { + await requestNotificationPermission(); + } else { + updateSetting("reminderEnabled", true); + } + } else { + updateSetting("reminderEnabled", false); + } + }; + + const saveApiKey = async () => { + setIsSaving(true); + try { + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ llmApiKey: apiKey }), + }); + if (res.ok) { + setSettings((prev) => prev ? { ...prev, hasLlmKey: Boolean(apiKey) } : null); + setShowApiKeyInput(false); + setApiKey(""); + } + } catch (error) { + console.error("Failed to save API key:", error); + } finally { + setIsSaving(false); + } + }; + + // Set up reminder check interval + useEffect(() => { + if (!settings?.reminderEnabled || !settings?.reminderTime) return; + + const checkReminder = () => { + const now = new Date(); + const [hours, minutes] = settings.reminderTime.split(":").map(Number); + + if (now.getHours() === hours && now.getMinutes() === minutes) { + if (Notification.permission === "granted") { + new Notification(APP_NAME, { + body: "Take a moment to reflect on what you're grateful for today.", + icon: "/icon.png", + }); + } + } + }; + + // Check every minute + const interval = setInterval(checkReminder, 60000); + return () => clearInterval(interval); + }, [settings?.reminderEnabled, settings?.reminderTime]); return ( @@ -32,59 +153,161 @@ export default function SettingsPage() {
)} - {/* Notifications - disabled in MVP */} -
-
+ {/* Admin - only show for admin users */} + {user?.isAdmin && ( + +
-

Daily reminder

-

- Get a gentle nudge to check in -

+

User Management

+

Add users, reset passwords

- -
-
+ + )} - {/* Sync - disabled in MVP */} -
-
-
-

Cloud sync

-

- Sync across devices (coming soon) -

+ {/* Daily Reminder */} + {!isLoading && settings && ( +
+
+
+ +
+

Daily reminder

+

+ Get a gentle nudge to check in +

+
+
+
- + {settings.reminderEnabled && ( +
+ + updateSetting("reminderTime", e.target.value)} + className="px-3 py-1 bg-background border border-border rounded-lg text-sm focus:outline-none focus:border-muted" + /> +
+ )} + {notificationPermission === "denied" && ( +

+ Notifications are blocked. Please enable them in your browser settings. +

+ )}
-
+ )} - {/* LLM Summaries - disabled in MVP */} -
-
-
-

AI summaries

-

- Monthly reflections with LLM (coming soon) -

+ {/* AI Reflections */} + {!isLoading && settings && ( +
+
+
+ +
+

AI reflections

+

+ Generate insights from your entries +

+
+
+ {settings.hasLlmKey ? ( + + ) : ( + + )}
- + + {settings.hasLlmKey && !showApiKeyInput && ( +
+ API key configured + + + + View reflections + +
+ )} + + {showApiKeyInput && ( +
+
+ +
+
+ + setApiKey(e.target.value)} + placeholder="sk-ant-..." + className="w-full pl-9 pr-3 py-2 bg-background border border-border rounded-lg text-sm focus:outline-none focus:border-muted" + /> +
+ +
+
+

+ Get your API key from{" "} + + console.anthropic.com + +

+ +
+ )}
-
+ )}

{APP_NAME} v0.1.0

diff --git a/src/components/AuthProvider.tsx b/src/components/AuthProvider.tsx index 532df51..686f981 100644 --- a/src/components/AuthProvider.tsx +++ b/src/components/AuthProvider.tsx @@ -7,6 +7,7 @@ interface User { id: string; email: string; name: string | null; + isAdmin: boolean; } interface AuthContextType { diff --git a/src/components/EntryForm.tsx b/src/components/EntryForm.tsx index 9ce2e72..d462de5 100644 --- a/src/components/EntryForm.tsx +++ b/src/components/EntryForm.tsx @@ -3,15 +3,18 @@ import { useEntry } from "@/hooks/useEntry"; import { MoodSelector } from "./MoodSelector"; import { TagInput } from "./TagInput"; -import { Check, Loader2 } from "lucide-react"; +import { Check, Loader2, Plus, Save } from "lucide-react"; interface EntryFormProps { date?: string; entryId?: string; + isNewEntry?: boolean; + onSaved?: () => void; } -export function EntryForm({ date, entryId }: EntryFormProps) { +export function EntryForm({ date, entryId, isNewEntry = false, onSaved }: EntryFormProps) { const { + entry, text, mood, roughDay, @@ -24,7 +27,21 @@ export function EntryForm({ date, entryId }: EntryFormProps) { updateRoughDay, addTag, removeTag, - } = useEntry({ date, entryId }); + save, + reset, + } = useEntry({ date, entryId, isNewEntry }); + + const handleSave = async () => { + const saved = await save(); + if (saved && onSaved) { + onSaved(); + } + }; + + const handleSaveAndNew = async () => { + await save(); + reset(); + }; if (isLoading) { return ( @@ -34,6 +51,8 @@ export function EntryForm({ date, entryId }: EntryFormProps) { ); } + const hasContent = text.trim().length > 0; + return (
{/* Main prompt */} @@ -80,21 +99,62 @@ export function EntryForm({ date, entryId }: EntryFormProps) { />
- {/* Save status */} -
- {isSaving ? ( + {/* Save buttons */} +
+ {isNewEntry ? ( <> - - Saving... + + - ) : lastSaved ? ( - <> - - Saved - - ) : text.trim() ? ( - Auto-saves as you type - ) : null} + ) : ( + + )} + + {/* Save status */} +
+ {isSaving ? ( + <> + + Saving... + + ) : lastSaved ? ( + <> + + Saved + + ) : hasContent ? ( + Auto-saves as you type + ) : null} +
); diff --git a/src/hooks/useEntry.ts b/src/hooks/useEntry.ts index 7facaa8..f6ccb99 100644 --- a/src/hooks/useEntry.ts +++ b/src/hooks/useEntry.ts @@ -9,9 +9,10 @@ import { AUTOSAVE_DELAY } from "@/lib/constants"; interface UseEntryOptions { date?: string; entryId?: string; + isNewEntry?: boolean; // If true, start with blank form } -export function useEntry({ date, entryId }: UseEntryOptions = {}) { +export function useEntry({ date, entryId, isNewEntry = false }: UseEntryOptions = {}) { const targetDate = date || getLocalDate(); const [entry, setEntry] = useState(null); const [text, setText] = useState(""); @@ -19,12 +20,17 @@ export function useEntry({ date, entryId }: UseEntryOptions = {}) { const [roughDay, setRoughDay] = useState(false); const [tagNames, setTagNames] = useState([]); const [isSaving, setIsSaving] = useState(false); - const [isLoading, setIsLoading] = useState(true); + const [isLoading, setIsLoading] = useState(!isNewEntry); const [lastSaved, setLastSaved] = useState(null); const pendingSaveRef = useRef(false); - // Load entry + // Load entry (only if editing existing or viewing by date) useEffect(() => { + if (isNewEntry) { + setIsLoading(false); + return; + } + async function loadEntry() { setIsLoading(true); try { @@ -35,13 +41,6 @@ export function useEntry({ date, entryId }: UseEntryOptions = {}) { if (res.ok) { data = await res.json(); } - } else { - // Load today's entry by date - const res = await fetch(`/api/entries?since=${targetDate}`); - if (res.ok) { - const entries: EntryWithTags[] = await res.json(); - data = entries.find((e) => e.date === targetDate) || null; - } } if (data) { @@ -59,17 +58,18 @@ export function useEntry({ date, entryId }: UseEntryOptions = {}) { } loadEntry(); - }, [targetDate, entryId]); + }, [targetDate, entryId, isNewEntry]); // Save function const save = useCallback(async () => { - if (!text.trim()) return; + if (!text.trim()) return null; setIsSaving(true); pendingSaveRef.current = false; try { const body: CreateEntryRequest = { + id: entry?.id, // Include id if updating existing date: entry?.date || targetDate, text: text.trim(), mood, @@ -87,13 +87,15 @@ export function useEntry({ date, entryId }: UseEntryOptions = {}) { const saved: EntryWithTags = await res.json(); setEntry(saved); setLastSaved(new Date()); + return saved; } } catch (error) { console.error("Failed to save entry:", error); } finally { setIsSaving(false); } - }, [text, mood, roughDay, tagNames, entry?.date, targetDate]); + return null; + }, [text, mood, roughDay, tagNames, entry?.id, entry?.date, targetDate]); // Debounced save const debouncedSave = useDebounce(() => { @@ -152,6 +154,17 @@ export function useEntry({ date, entryId }: UseEntryOptions = {}) { [markDirty] ); + // Reset form for new entry + const reset = useCallback(() => { + setEntry(null); + setText(""); + setMood(null); + setRoughDay(false); + setTagNames([]); + setLastSaved(null); + pendingSaveRef.current = false; + }, []); + // Save on unmount if pending useEffect(() => { return () => { @@ -161,6 +174,7 @@ export function useEntry({ date, entryId }: UseEntryOptions = {}) { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ + id: entry?.id, date: entry?.date || targetDate, text: text.trim(), mood, @@ -170,7 +184,7 @@ export function useEntry({ date, entryId }: UseEntryOptions = {}) { }).catch(() => {}); } }; - }, [text, mood, roughDay, tagNames, entry?.date, targetDate]); + }, [text, mood, roughDay, tagNames, entry?.id, entry?.date, targetDate]); return { entry, @@ -187,5 +201,6 @@ export function useEntry({ date, entryId }: UseEntryOptions = {}) { addTag, removeTag, save, + reset, }; } diff --git a/src/lib/auth.ts b/src/lib/auth.ts index a8c5231..7645a60 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -11,6 +11,7 @@ export interface AuthUser { id: string; email: string; name: string | null; + isAdmin: boolean; } export async function hashPassword(password: string): Promise { @@ -68,6 +69,7 @@ export async function getSession(): Promise { userId: schema.users.id, email: schema.users.email, name: schema.users.name, + isAdmin: schema.users.isAdmin, expiresAt: schema.sessions.expiresAt, }) .from(schema.sessions) @@ -83,6 +85,7 @@ export async function getSession(): Promise { id: result[0].userId, email: result[0].email, name: result[0].name, + isAdmin: result[0].isAdmin === 1, }; } @@ -122,6 +125,7 @@ export async function registerUser( id: userId, email: email.toLowerCase(), name: name || null, + isAdmin: false, }, }; } @@ -154,6 +158,7 @@ export async function loginUser( id: user.id, email: user.email, name: user.name, + isAdmin: user.isAdmin === 1, }, sessionId, }; diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 0ff6fa8..4f50499 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -5,6 +5,10 @@ export const users = sqliteTable("users", { email: text("email").notNull().unique(), passwordHash: text("password_hash").notNull(), name: text("name"), + isAdmin: integer("is_admin").notNull().default(0), // 1 = admin + reminderEnabled: integer("reminder_enabled").notNull().default(0), + reminderTime: text("reminder_time"), // HH:MM format + llmApiKey: text("llm_api_key"), // Encrypted API key for LLM createdAt: integer("created_at").notNull(), // Unix ms }); diff --git a/src/lib/types.ts b/src/lib/types.ts index 53544e9..f49c660 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -7,6 +7,7 @@ export interface EntryWithTags extends Entry { // API request/response types export interface CreateEntryRequest { + id?: string; // If provided, updates existing entry date: string; text: string; mood?: number | null;