From bf9da84553fd1aa4cc7d10a26f849ffd55de264e Mon Sep 17 00:00:00 2001 From: Avery Felts Date: Sun, 25 Jan 2026 18:10:04 -0700 Subject: [PATCH] fix: Replace Prisma with native D1 and fix timezone issues - Created native D1 database layer (src/lib/d1.ts) to bypass Prisma fs.readdir issues - Updated all API routes to use direct D1 queries - Added date-utils.ts with local date helpers to fix UTC timezone mismatch - Calendar now correctly colors today's usage - Data persists correctly across page refreshes --- next.config.ts | 3 +- src/app/api/achievements/route.ts | 41 +--- src/app/api/preferences/route.ts | 66 ++---- src/app/api/reminders/route.ts | 33 ++- src/app/api/savings/route.ts | 41 ++-- src/app/api/usage/route.ts | 92 ++------ src/components/Dashboard.tsx | 5 +- src/components/StatsCard.tsx | 5 +- src/components/UsageCalendar.tsx | 9 +- src/components/UsagePromptDialog.tsx | 3 +- src/hooks/use-storage.ts | 3 +- src/lib/d1.ts | 329 +++++++++++++++++++++++++++ src/lib/date-utils.ts | 17 ++ src/lib/prisma.ts | 14 +- wrangler.jsonc | 9 +- 15 files changed, 460 insertions(+), 210 deletions(-) create mode 100644 src/lib/d1.ts create mode 100644 src/lib/date-utils.ts diff --git a/next.config.ts b/next.config.ts index c4ffb47..fc503f4 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,8 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - // Required for OpenNext/Cloudflare Workers - serverExternalPackages: ["@prisma/client"], + // No special configuration needed - using native D1 API }; export default nextConfig; diff --git a/src/app/api/achievements/route.ts b/src/app/api/achievements/route.ts index fe490c6..856f9a1 100644 --- a/src/app/api/achievements/route.ts +++ b/src/app/api/achievements/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getPrismaWithD1 } from '@/lib/prisma'; import { getSession } from '@/lib/session'; +import { getAchievementsD1, getAchievementD1, createAchievementD1 } from '@/lib/d1'; export async function GET() { try { @@ -9,16 +9,12 @@ export async function GET() { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } - const prisma = await getPrismaWithD1(); - const achievements = await prisma.achievement.findMany({ - where: { userId: session.user.id }, - orderBy: { unlockedAt: 'desc' }, - }); + const achievements = await getAchievementsD1(session.user.id); return NextResponse.json( achievements.map((a) => ({ badgeId: a.badgeId, - unlockedAt: a.unlockedAt.toISOString(), + unlockedAt: a.unlockedAt, substance: a.substance, })) ); @@ -42,39 +38,26 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Missing badgeId or substance' }, { status: 400 }); } - const prisma = await getPrismaWithD1(); - - // Check if achievement already exists - const existing = await prisma.achievement.findUnique({ - where: { - userId_badgeId_substance: { - userId: session.user.id, - badgeId, - substance, - }, - }, - }); - + // Check if already exists + const existing = await getAchievementD1(session.user.id, badgeId, substance); if (existing) { return NextResponse.json({ badgeId: existing.badgeId, - unlockedAt: existing.unlockedAt.toISOString(), + unlockedAt: existing.unlockedAt, substance: existing.substance, alreadyUnlocked: true, }); } - const achievement = await prisma.achievement.create({ - data: { - userId: session.user.id, - badgeId, - substance, - }, - }); + const achievement = await createAchievementD1(session.user.id, badgeId, substance); + + if (!achievement) { + return NextResponse.json({ error: 'Failed to unlock achievement' }, { status: 500 }); + } return NextResponse.json({ badgeId: achievement.badgeId, - unlockedAt: achievement.unlockedAt.toISOString(), + unlockedAt: achievement.unlockedAt, substance: achievement.substance, alreadyUnlocked: false, }); diff --git a/src/app/api/preferences/route.ts b/src/app/api/preferences/route.ts index fe117fe..d83388f 100644 --- a/src/app/api/preferences/route.ts +++ b/src/app/api/preferences/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getPrismaWithD1 } from '@/lib/prisma'; import { getSession } from '@/lib/session'; +import { getPreferencesD1, upsertPreferencesD1 } from '@/lib/d1'; export async function GET() { try { @@ -9,10 +9,7 @@ export async function GET() { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } - const prisma = await getPrismaWithD1(); - const preferences = await prisma.userPreferences.findUnique({ - where: { userId: session.user.id }, - }); + const preferences = await getPreferencesD1(session.user.id); if (!preferences) { return NextResponse.json({ @@ -29,7 +26,7 @@ export async function GET() { return NextResponse.json({ substance: preferences.substance, trackingStartDate: preferences.trackingStartDate, - hasCompletedSetup: preferences.hasCompletedSetup, + hasCompletedSetup: !!preferences.hasCompletedSetup, dailyGoal: preferences.dailyGoal, quitPlan: preferences.quitPlanJson ? JSON.parse(preferences.quitPlanJson) : null, userName: preferences.userName, @@ -63,53 +60,28 @@ export async function POST(request: NextRequest) { lastNicotineUsageTime?: string; lastWeedUsageTime?: string; }; - const { - substance, - trackingStartDate, - hasCompletedSetup, - dailyGoal, - quitPlan, - userName, - userAge, - religion, - lastNicotineUsageTime, - lastWeedUsageTime - } = body; - const prisma = await getPrismaWithD1(); - const preferences = await prisma.userPreferences.upsert({ - where: { userId: session.user.id }, - update: { - substance, - trackingStartDate, - hasCompletedSetup, - dailyGoal, - quitPlanJson: quitPlan ? JSON.stringify(quitPlan) : null, - userName, - userAge, - religion, - lastNicotineUsageTime, - lastWeedUsageTime, - }, - create: { - userId: session.user.id, - substance, - trackingStartDate, - hasCompletedSetup, - dailyGoal, - quitPlanJson: quitPlan ? JSON.stringify(quitPlan) : null, - userName, - userAge, - religion, - lastNicotineUsageTime, - lastWeedUsageTime, - }, + const preferences = await upsertPreferencesD1(session.user.id, { + substance: body.substance, + trackingStartDate: body.trackingStartDate, + hasCompletedSetup: body.hasCompletedSetup ? 1 : 0, + dailyGoal: body.dailyGoal, + quitPlanJson: body.quitPlan ? JSON.stringify(body.quitPlan) : undefined, + userName: body.userName, + userAge: body.userAge, + religion: body.religion, + lastNicotineUsageTime: body.lastNicotineUsageTime, + lastWeedUsageTime: body.lastWeedUsageTime, }); + if (!preferences) { + return NextResponse.json({ error: 'Failed to save preferences' }, { status: 500 }); + } + return NextResponse.json({ substance: preferences.substance, trackingStartDate: preferences.trackingStartDate, - hasCompletedSetup: preferences.hasCompletedSetup, + hasCompletedSetup: !!preferences.hasCompletedSetup, dailyGoal: preferences.dailyGoal, quitPlan: preferences.quitPlanJson ? JSON.parse(preferences.quitPlanJson) : null, userName: preferences.userName, diff --git a/src/app/api/reminders/route.ts b/src/app/api/reminders/route.ts index 73ad3ac..6ed4292 100644 --- a/src/app/api/reminders/route.ts +++ b/src/app/api/reminders/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getPrismaWithD1 } from '@/lib/prisma'; import { getSession } from '@/lib/session'; +import { getReminderSettingsD1, upsertReminderSettingsD1 } from '@/lib/d1'; export async function GET() { try { @@ -9,10 +9,7 @@ export async function GET() { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } - const prisma = await getPrismaWithD1(); - const settings = await prisma.reminderSettings.findUnique({ - where: { userId: session.user.id }, - }); + const settings = await getReminderSettingsD1(session.user.id); if (!settings) { return NextResponse.json({ @@ -22,7 +19,7 @@ export async function GET() { } return NextResponse.json({ - enabled: settings.enabled, + enabled: !!settings.enabled, reminderTime: settings.reminderTime, }); } catch (error) { @@ -41,22 +38,18 @@ export async function POST(request: NextRequest) { const body = await request.json() as { enabled?: boolean; reminderTime?: string }; const { enabled, reminderTime } = body; - const prisma = await getPrismaWithD1(); - const settings = await prisma.reminderSettings.upsert({ - where: { userId: session.user.id }, - update: { - enabled: enabled ?? false, - reminderTime: reminderTime ?? '09:00', - }, - create: { - userId: session.user.id, - enabled: enabled ?? false, - reminderTime: reminderTime ?? '09:00', - }, - }); + const settings = await upsertReminderSettingsD1( + session.user.id, + enabled ?? false, + reminderTime ?? '09:00' + ); + + if (!settings) { + return NextResponse.json({ error: 'Failed to save reminder settings' }, { status: 500 }); + } return NextResponse.json({ - enabled: settings.enabled, + enabled: !!settings.enabled, reminderTime: settings.reminderTime, }); } catch (error) { diff --git a/src/app/api/savings/route.ts b/src/app/api/savings/route.ts index 38448e1..cd5f80e 100644 --- a/src/app/api/savings/route.ts +++ b/src/app/api/savings/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getPrismaWithD1 } from '@/lib/prisma'; import { getSession } from '@/lib/session'; +import { getSavingsConfigD1, upsertSavingsConfigD1 } from '@/lib/d1'; export async function GET() { try { @@ -9,10 +9,7 @@ export async function GET() { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } - const prisma = await getPrismaWithD1(); - const config = await prisma.savingsConfig.findUnique({ - where: { userId: session.user.id }, - }); + const config = await getSavingsConfigD1(session.user.id); if (!config) { return NextResponse.json(null); @@ -53,27 +50,19 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); } - const prisma = await getPrismaWithD1(); - const config = await prisma.savingsConfig.upsert({ - where: { userId: session.user.id }, - update: { - costPerUnit, - unitsPerDay, - savingsGoal: savingsGoal ?? null, - goalName: goalName ?? null, - currency: currency ?? 'USD', - substance, - }, - create: { - userId: session.user.id, - costPerUnit, - unitsPerDay, - savingsGoal: savingsGoal ?? null, - goalName: goalName ?? null, - currency: currency ?? 'USD', - substance, - }, - }); + const config = await upsertSavingsConfigD1( + session.user.id, + costPerUnit, + unitsPerDay, + substance, + savingsGoal, + goalName, + currency + ); + + if (!config) { + return NextResponse.json({ error: 'Failed to save savings config' }, { status: 500 }); + } return NextResponse.json({ costPerUnit: config.costPerUnit, diff --git a/src/app/api/usage/route.ts b/src/app/api/usage/route.ts index 1a913fd..37cc3df 100644 --- a/src/app/api/usage/route.ts +++ b/src/app/api/usage/route.ts @@ -1,6 +1,11 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getPrismaWithD1 } from '@/lib/prisma'; import { getSession } from '@/lib/session'; +import { + getUsageEntriesD1, + getUsageEntryD1, + upsertUsageEntryD1, + deleteUsageEntryD1 +} from '@/lib/d1'; export async function GET() { try { @@ -9,11 +14,7 @@ export async function GET() { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } - const prisma = await getPrismaWithD1(); - const entries = await prisma.usageEntry.findMany({ - where: { userId: session.user.id }, - orderBy: { date: 'desc' }, - }); + const entries = await getUsageEntriesD1(session.user.id); return NextResponse.json( entries.map((e) => ({ @@ -42,44 +43,18 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); } - const prisma = await getPrismaWithD1(); + // Add to existing count + const entry = await upsertUsageEntryD1(session.user.id, date, count, substance, true); - // Upsert: add to existing count or create new entry - const existing = await prisma.usageEntry.findUnique({ - where: { - userId_date_substance: { - userId: session.user.id, - date: date, - substance: substance, - }, - }, - }); - - if (existing) { - const updated = await prisma.usageEntry.update({ - where: { id: existing.id }, - data: { count: existing.count + count }, - }); - return NextResponse.json({ - date: updated.date, - count: updated.count, - substance: updated.substance, - }); - } else { - const created = await prisma.usageEntry.create({ - data: { - userId: session.user.id, - date: date, - count: count, - substance: substance, - }, - }); - return NextResponse.json({ - date: created.date, - count: created.count, - substance: created.substance, - }); + if (!entry) { + return NextResponse.json({ error: 'Failed to save usage entry' }, { status: 500 }); } + + return NextResponse.json({ + date: entry.date, + count: entry.count, + substance: entry.substance, + }); } catch (error) { console.error('Error saving usage entry:', error); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); @@ -100,25 +75,12 @@ export async function PUT(request: NextRequest) { return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); } - const prisma = await getPrismaWithD1(); - // Set the exact count (replace, not add) - const entry = await prisma.usageEntry.upsert({ - where: { - userId_date_substance: { - userId: session.user.id, - date: date, - substance: substance, - }, - }, - update: { count: count }, - create: { - userId: session.user.id, - date: date, - count: count, - substance: substance, - }, - }); + const entry = await upsertUsageEntryD1(session.user.id, date, count, substance, false); + + if (!entry) { + return NextResponse.json({ error: 'Failed to update usage entry' }, { status: 500 }); + } return NextResponse.json({ date: entry.date, @@ -146,15 +108,7 @@ export async function DELETE(request: NextRequest) { return NextResponse.json({ error: 'Missing date or substance' }, { status: 400 }); } - const prisma = await getPrismaWithD1(); - - await prisma.usageEntry.deleteMany({ - where: { - userId: session.user.id, - date, - substance, - }, - }); + await deleteUsageEntryD1(session.user.id, date, substance); return NextResponse.json({ success: true }); } catch (error) { diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index 9353671..4d75a9e 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -35,6 +35,7 @@ import { SavingsTrackerCard } from './SavingsTrackerCard'; import { Button } from '@/components/ui/button'; import { PlusCircle } from 'lucide-react'; import { useTheme } from '@/lib/theme-context'; +import { getTodayString } from '@/lib/date-utils'; interface DashboardProps { user: User; @@ -118,7 +119,7 @@ export function Dashboard({ user }: DashboardProps) { }, [loadData, checkAndUnlockAchievements]); const handleSetupComplete = async (data: { substance: 'nicotine' | 'weed'; name: string; age: number; religion: 'christian' | 'muslim' | 'jewish' | 'secular' }) => { - const today = new Date().toISOString().split('T')[0]; + const today = getTodayString(); const newPrefs: UserPreferences = { substance: data.substance, trackingStartDate: today, @@ -143,7 +144,7 @@ export function Dashboard({ user }: DashboardProps) { } if (count > 0) { - const today = new Date().toISOString().split('T')[0]; + const today = getTodayString(); const now = new Date().toISOString(); await saveUsageEntryAsync({ diff --git a/src/components/StatsCard.tsx b/src/components/StatsCard.tsx index 3a7f863..d904734 100644 --- a/src/components/StatsCard.tsx +++ b/src/components/StatsCard.tsx @@ -4,6 +4,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { UsageEntry } from '@/lib/storage'; import { Cigarette, Leaf } from 'lucide-react'; import { useTheme } from '@/lib/theme-context'; +import { getTodayString, getLocalDateString } from '@/lib/date-utils'; interface StatsCardProps { usageData: UsageEntry[]; @@ -16,7 +17,7 @@ export function StatsCard({ usageData, substance }: StatsCardProps) { // Calculate stats const today = new Date(); - const todayStr = today.toISOString().split('T')[0]; + const todayStr = getTodayString(); const todayUsage = substanceData.find((e) => e.date === todayStr)?.count ?? 0; // Last 7 days @@ -37,7 +38,7 @@ export function StatsCard({ usageData, substance }: StatsCardProps) { for (let i = 0; i <= 30; i++) { const checkDate = new Date(today); checkDate.setDate(checkDate.getDate() - i); - const dateStr = checkDate.toISOString().split('T')[0]; + const dateStr = getLocalDateString(checkDate); const dayUsage = substanceData.find((e) => e.date === dateStr)?.count ?? -1; if (dayUsage === 0) { diff --git a/src/components/UsageCalendar.tsx b/src/components/UsageCalendar.tsx index 7f72eaf..f543b66 100644 --- a/src/components/UsageCalendar.tsx +++ b/src/components/UsageCalendar.tsx @@ -15,6 +15,7 @@ import { Label } from '@/components/ui/label'; import { UsageEntry, UserPreferences, setUsageForDateAsync, clearDayDataAsync } from '@/lib/storage'; import { ChevronLeftIcon, ChevronRightIcon, Cigarette, Leaf, Sparkles } from 'lucide-react'; import { useTheme } from '@/lib/theme-context'; +import { getLocalDateString, getTodayString } from '@/lib/date-utils'; import { DailyInspirationCard } from './DailyInspirationCard'; @@ -37,7 +38,7 @@ export function UsageCalendar({ usageData, onDataUpdate, religion, onReligionUpd const { theme } = useTheme(); const getUsageForDate = (date: Date, substance: 'nicotine' | 'weed'): number => { - const dateStr = date.toISOString().split('T')[0]; + const dateStr = getLocalDateString(date); const entry = usageData.find((e) => e.date === dateStr && e.substance === substance); return entry?.count ?? 0; }; @@ -60,8 +61,8 @@ export function UsageCalendar({ usageData, onDataUpdate, religion, onReligionUpd const handleSave = async () => { if (selectedDate) { - const dateStr = selectedDate.toISOString().split('T')[0]; - const todayStr = new Date().toISOString().split('T')[0]; + const dateStr = getLocalDateString(selectedDate); + const todayStr = getTodayString(); const newNicotineCount = parseInt(editNicotineCount, 10) || 0; const newWeedCount = parseInt(editWeedCount, 10) || 0; @@ -105,7 +106,7 @@ export function UsageCalendar({ usageData, onDataUpdate, religion, onReligionUpd const handleClearDay = async () => { if (selectedDate) { - const dateStr = selectedDate.toISOString().split('T')[0]; + const dateStr = getLocalDateString(selectedDate); await Promise.all([ clearDayDataAsync(dateStr, 'nicotine'), clearDayDataAsync(dateStr, 'weed'), diff --git a/src/components/UsagePromptDialog.tsx b/src/components/UsagePromptDialog.tsx index 11d4ed0..37292c8 100644 --- a/src/components/UsagePromptDialog.tsx +++ b/src/components/UsagePromptDialog.tsx @@ -12,6 +12,7 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { getUsageForDate } from '@/lib/storage'; +import { getTodayString } from '@/lib/date-utils'; import { Cigarette, Leaf } from 'lucide-react'; interface UsagePromptDialogProps { @@ -31,7 +32,7 @@ export function UsagePromptDialog({ const [selectedSubstance, setSelectedSubstance] = useState<'nicotine' | 'weed' | null>(null); const [count, setCount] = useState('1'); - const today = new Date().toISOString().split('T')[0]; + const today = getTodayString(); const nicotineCount = typeof window !== 'undefined' ? getUsageForDate(today, 'nicotine', userId) : 0; const weedCount = typeof window !== 'undefined' ? getUsageForDate(today, 'weed', userId) : 0; diff --git a/src/hooks/use-storage.ts b/src/hooks/use-storage.ts index 7318443..b54d3c1 100644 --- a/src/hooks/use-storage.ts +++ b/src/hooks/use-storage.ts @@ -16,6 +16,7 @@ import { UsageEntry, QuitPlan, } from "@/lib/storage"; +import { getTodayString } from "@/lib/date-utils"; type SubstanceType = 'nicotine' | 'weed'; @@ -90,7 +91,7 @@ export function useUsageLogs() { const logUsage = useCallback( (puffs: number, date?: string) => { const prefs = getPreferences(); - const logDate = date || new Date().toISOString().split("T")[0]; + const logDate = date || getTodayString(); const newEntry: UsageEntry = { date: logDate, count: puffs, diff --git a/src/lib/d1.ts b/src/lib/d1.ts new file mode 100644 index 0000000..ccd58fb --- /dev/null +++ b/src/lib/d1.ts @@ -0,0 +1,329 @@ +// Native D1 database client for Cloudflare Workers +// This bypasses Prisma which has compatibility issues with the Workers runtime + +export interface D1Result { + results: T[]; + success: boolean; + meta: { + duration: number; + }; +} + +/** + * Get the D1 database binding from Cloudflare context + */ +export function getD1(): D1Database | null { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { getCloudflareContext } = require('@opennextjs/cloudflare'); + const ctx = getCloudflareContext(); + return ctx?.env?.DB || null; + } catch (error) { + console.error('[getD1] Failed to get Cloudflare context:', error); + return null; + } +} + +// ============ USER PREFERENCES ============ + +export interface UserPreferencesRow { + id: string; + userId: string; + substance: string; + trackingStartDate: string | null; + hasCompletedSetup: number; // SQLite boolean + dailyGoal: number | null; + userName: string | null; + userAge: number | null; + religion: string | null; + lastNicotineUsageTime: string | null; + lastWeedUsageTime: string | null; + quitPlanJson: string | null; + createdAt: string; + updatedAt: string; +} + +export async function getPreferencesD1(userId: string): Promise { + const db = getD1(); + if (!db) return null; + + const result = await db.prepare( + 'SELECT * FROM UserPreferences WHERE userId = ?' + ).bind(userId).first(); + + return result; +} + +export async function upsertPreferencesD1(userId: string, data: Partial): Promise { + const db = getD1(); + if (!db) return null; + + const existing = await getPreferencesD1(userId); + const now = new Date().toISOString(); + const id = existing?.id || crypto.randomUUID(); + + if (existing) { + // Update + const updates: string[] = []; + const values: unknown[] = []; + + if (data.substance !== undefined) { updates.push('substance = ?'); values.push(data.substance); } + if (data.trackingStartDate !== undefined) { updates.push('trackingStartDate = ?'); values.push(data.trackingStartDate); } + if (data.hasCompletedSetup !== undefined) { updates.push('hasCompletedSetup = ?'); values.push(data.hasCompletedSetup ? 1 : 0); } + if (data.dailyGoal !== undefined) { updates.push('dailyGoal = ?'); values.push(data.dailyGoal); } + if (data.userName !== undefined) { updates.push('userName = ?'); values.push(data.userName); } + if (data.userAge !== undefined) { updates.push('userAge = ?'); values.push(data.userAge); } + if (data.religion !== undefined) { updates.push('religion = ?'); values.push(data.religion); } + if (data.lastNicotineUsageTime !== undefined) { updates.push('lastNicotineUsageTime = ?'); values.push(data.lastNicotineUsageTime); } + if (data.lastWeedUsageTime !== undefined) { updates.push('lastWeedUsageTime = ?'); values.push(data.lastWeedUsageTime); } + if (data.quitPlanJson !== undefined) { updates.push('quitPlanJson = ?'); values.push(data.quitPlanJson); } + + updates.push('updatedAt = ?'); + values.push(now); + values.push(userId); + + await db.prepare( + `UPDATE UserPreferences SET ${updates.join(', ')} WHERE userId = ?` + ).bind(...values).run(); + } else { + // Insert + await db.prepare( + `INSERT INTO UserPreferences (id, userId, substance, trackingStartDate, hasCompletedSetup, dailyGoal, userName, userAge, religion, lastNicotineUsageTime, lastWeedUsageTime, quitPlanJson, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).bind( + id, + userId, + data.substance || 'nicotine', + data.trackingStartDate || null, + data.hasCompletedSetup ? 1 : 0, + data.dailyGoal || null, + data.userName || null, + data.userAge || null, + data.religion || null, + data.lastNicotineUsageTime || null, + data.lastWeedUsageTime || null, + data.quitPlanJson || null, + now, + now + ).run(); + } + + return getPreferencesD1(userId); +} + +// ============ USAGE ENTRIES ============ + +export interface UsageEntryRow { + id: string; + userId: string; + date: string; + count: number; + substance: string; + createdAt: string; + updatedAt: string; +} + +export async function getUsageEntriesD1(userId: string): Promise { + const db = getD1(); + if (!db) return []; + + const result = await db.prepare( + 'SELECT * FROM UsageEntry WHERE userId = ? ORDER BY date DESC' + ).bind(userId).all(); + + return result.results || []; +} + +export async function getUsageEntryD1(userId: string, date: string, substance: string): Promise { + const db = getD1(); + if (!db) return null; + + const result = await db.prepare( + 'SELECT * FROM UsageEntry WHERE userId = ? AND date = ? AND substance = ?' + ).bind(userId, date, substance).first(); + + return result; +} + +export async function upsertUsageEntryD1(userId: string, date: string, count: number, substance: string, addToExisting = false): Promise { + const db = getD1(); + if (!db) return null; + + const existing = await getUsageEntryD1(userId, date, substance); + const now = new Date().toISOString(); + const id = existing?.id || crypto.randomUUID(); + + if (existing) { + const newCount = addToExisting ? existing.count + count : count; + await db.prepare( + 'UPDATE UsageEntry SET count = ?, updatedAt = ? WHERE id = ?' + ).bind(newCount, now, existing.id).run(); + } else { + await db.prepare( + `INSERT INTO UsageEntry (id, userId, date, count, substance, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ).bind(id, userId, date, count, substance, now, now).run(); + } + + return getUsageEntryD1(userId, date, substance); +} + +export async function deleteUsageEntryD1(userId: string, date: string, substance: string): Promise { + const db = getD1(); + if (!db) return; + + await db.prepare( + 'DELETE FROM UsageEntry WHERE userId = ? AND date = ? AND substance = ?' + ).bind(userId, date, substance).run(); +} + +// ============ ACHIEVEMENTS ============ + +export interface AchievementRow { + id: string; + userId: string; + badgeId: string; + unlockedAt: string; + substance: string; +} + +export async function getAchievementsD1(userId: string): Promise { + const db = getD1(); + if (!db) return []; + + const result = await db.prepare( + 'SELECT * FROM Achievement WHERE userId = ? ORDER BY unlockedAt DESC' + ).bind(userId).all(); + + return result.results || []; +} + +export async function getAchievementD1(userId: string, badgeId: string, substance: string): Promise { + const db = getD1(); + if (!db) return null; + + const result = await db.prepare( + 'SELECT * FROM Achievement WHERE userId = ? AND badgeId = ? AND substance = ?' + ).bind(userId, badgeId, substance).first(); + + return result; +} + +export async function createAchievementD1(userId: string, badgeId: string, substance: string): Promise { + const db = getD1(); + if (!db) return null; + + const existing = await getAchievementD1(userId, badgeId, substance); + if (existing) return existing; + + const id = crypto.randomUUID(); + const now = new Date().toISOString(); + + await db.prepare( + `INSERT INTO Achievement (id, userId, badgeId, unlockedAt, substance) + VALUES (?, ?, ?, ?, ?)` + ).bind(id, userId, badgeId, now, substance).run(); + + return getAchievementD1(userId, badgeId, substance); +} + +// ============ REMINDER SETTINGS ============ + +export interface ReminderSettingsRow { + id: string; + userId: string; + enabled: number; // SQLite boolean + reminderTime: string; + lastNotifiedDate: string | null; + createdAt: string; + updatedAt: string; +} + +export async function getReminderSettingsD1(userId: string): Promise { + const db = getD1(); + if (!db) return null; + + const result = await db.prepare( + 'SELECT * FROM ReminderSettings WHERE userId = ?' + ).bind(userId).first(); + + return result; +} + +export async function upsertReminderSettingsD1(userId: string, enabled: boolean, reminderTime: string): Promise { + const db = getD1(); + if (!db) return null; + + const existing = await getReminderSettingsD1(userId); + const now = new Date().toISOString(); + const id = existing?.id || crypto.randomUUID(); + + if (existing) { + await db.prepare( + 'UPDATE ReminderSettings SET enabled = ?, reminderTime = ?, updatedAt = ? WHERE userId = ?' + ).bind(enabled ? 1 : 0, reminderTime, now, userId).run(); + } else { + await db.prepare( + `INSERT INTO ReminderSettings (id, userId, enabled, reminderTime, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?)` + ).bind(id, userId, enabled ? 1 : 0, reminderTime, now, now).run(); + } + + return getReminderSettingsD1(userId); +} + +// ============ SAVINGS CONFIG ============ + +export interface SavingsConfigRow { + id: string; + userId: string; + costPerUnit: number; + unitsPerDay: number; + savingsGoal: number | null; + goalName: string | null; + currency: string; + substance: string; + createdAt: string; + updatedAt: string; +} + +export async function getSavingsConfigD1(userId: string): Promise { + const db = getD1(); + if (!db) return null; + + const result = await db.prepare( + 'SELECT * FROM SavingsConfig WHERE userId = ?' + ).bind(userId).first(); + + return result; +} + +export async function upsertSavingsConfigD1( + userId: string, + costPerUnit: number, + unitsPerDay: number, + substance: string, + savingsGoal?: number | null, + goalName?: string | null, + currency?: string +): Promise { + const db = getD1(); + if (!db) return null; + + const existing = await getSavingsConfigD1(userId); + const now = new Date().toISOString(); + const id = existing?.id || crypto.randomUUID(); + + if (existing) { + await db.prepare( + 'UPDATE SavingsConfig SET costPerUnit = ?, unitsPerDay = ?, savingsGoal = ?, goalName = ?, currency = ?, substance = ?, updatedAt = ? WHERE userId = ?' + ).bind(costPerUnit, unitsPerDay, savingsGoal ?? null, goalName ?? null, currency || 'USD', substance, now, userId).run(); + } else { + await db.prepare( + `INSERT INTO SavingsConfig (id, userId, costPerUnit, unitsPerDay, savingsGoal, goalName, currency, substance, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).bind(id, userId, costPerUnit, unitsPerDay, savingsGoal ?? null, goalName ?? null, currency || 'USD', substance, now, now).run(); + } + + return getSavingsConfigD1(userId); +} diff --git a/src/lib/date-utils.ts b/src/lib/date-utils.ts new file mode 100644 index 0000000..298efc7 --- /dev/null +++ b/src/lib/date-utils.ts @@ -0,0 +1,17 @@ +/** + * Get a date string in YYYY-MM-DD format using LOCAL timezone. + * This prevents timezone issues where evening times become the next day in UTC. + */ +export function getLocalDateString(date: Date = new Date()): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +/** + * Get today's date string in YYYY-MM-DD format using LOCAL timezone. + */ +export function getTodayString(): string { + return getLocalDateString(new Date()); +} diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts index cd10ece..b8b0188 100644 --- a/src/lib/prisma.ts +++ b/src/lib/prisma.ts @@ -10,17 +10,19 @@ const globalForPrisma = globalThis as unknown as { * Get Prisma client for Cloudflare Workers with D1. * This should be called in each request handler to get a fresh client with D1 binding. */ -export async function getPrismaWithD1(): Promise { +export function getPrismaWithD1(): PrismaClient { // Try to get Cloudflare context (only works in Workers environment) try { - const { getCloudflareContext } = await import('@opennextjs/cloudflare'); - const { env } = await getCloudflareContext(); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { getCloudflareContext } = require('@opennextjs/cloudflare'); + const ctx = getCloudflareContext(); - if (env?.DB) { - const adapter = new PrismaD1(env.DB); + if (ctx?.env?.DB) { + const adapter = new PrismaD1(ctx.env.DB); return new PrismaClient({ adapter }); } - } catch { + } catch (error) { + console.error('[getPrismaWithD1] Failed to get Cloudflare context:', error); // Not in Cloudflare Workers environment, fall back to local client } diff --git a/wrangler.jsonc b/wrangler.jsonc index 9269a55..166ef18 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -35,5 +35,12 @@ "pattern": "www.quittraq.com/*", "zone_name": "quittraq.com" } - ] + ], + // Observability + "observability": { + "logs": { + "enabled": false, + "invocation_logs": true + } + } } \ No newline at end of file