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
This commit is contained in:
Avery Felts 2026-01-25 18:10:04 -07:00
parent 14c45eeb24
commit bf9da84553
15 changed files with 460 additions and 210 deletions

View File

@ -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;

View File

@ -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,
});

View File

@ -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,

View File

@ -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) {

View File

@ -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: {
const config = await upsertSavingsConfigD1(
session.user.id,
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,
},
});
savingsGoal,
goalName,
currency
);
if (!config) {
return NextResponse.json({ error: 'Failed to save savings config' }, { status: 500 });
}
return NextResponse.json({
costPerUnit: config.costPerUnit,

View File

@ -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) {

View File

@ -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({

View File

@ -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) {

View File

@ -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'),

View File

@ -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;

View File

@ -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,

329
src/lib/d1.ts Normal file
View File

@ -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<T> {
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<UserPreferencesRow | null> {
const db = getD1();
if (!db) return null;
const result = await db.prepare(
'SELECT * FROM UserPreferences WHERE userId = ?'
).bind(userId).first<UserPreferencesRow>();
return result;
}
export async function upsertPreferencesD1(userId: string, data: Partial<UserPreferencesRow>): Promise<UserPreferencesRow | null> {
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<UsageEntryRow[]> {
const db = getD1();
if (!db) return [];
const result = await db.prepare(
'SELECT * FROM UsageEntry WHERE userId = ? ORDER BY date DESC'
).bind(userId).all<UsageEntryRow>();
return result.results || [];
}
export async function getUsageEntryD1(userId: string, date: string, substance: string): Promise<UsageEntryRow | null> {
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<UsageEntryRow>();
return result;
}
export async function upsertUsageEntryD1(userId: string, date: string, count: number, substance: string, addToExisting = false): Promise<UsageEntryRow | null> {
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<void> {
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<AchievementRow[]> {
const db = getD1();
if (!db) return [];
const result = await db.prepare(
'SELECT * FROM Achievement WHERE userId = ? ORDER BY unlockedAt DESC'
).bind(userId).all<AchievementRow>();
return result.results || [];
}
export async function getAchievementD1(userId: string, badgeId: string, substance: string): Promise<AchievementRow | null> {
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<AchievementRow>();
return result;
}
export async function createAchievementD1(userId: string, badgeId: string, substance: string): Promise<AchievementRow | null> {
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<ReminderSettingsRow | null> {
const db = getD1();
if (!db) return null;
const result = await db.prepare(
'SELECT * FROM ReminderSettings WHERE userId = ?'
).bind(userId).first<ReminderSettingsRow>();
return result;
}
export async function upsertReminderSettingsD1(userId: string, enabled: boolean, reminderTime: string): Promise<ReminderSettingsRow | null> {
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<SavingsConfigRow | null> {
const db = getD1();
if (!db) return null;
const result = await db.prepare(
'SELECT * FROM SavingsConfig WHERE userId = ?'
).bind(userId).first<SavingsConfigRow>();
return result;
}
export async function upsertSavingsConfigD1(
userId: string,
costPerUnit: number,
unitsPerDay: number,
substance: string,
savingsGoal?: number | null,
goalName?: string | null,
currency?: string
): Promise<SavingsConfigRow | null> {
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);
}

17
src/lib/date-utils.ts Normal file
View File

@ -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());
}

View File

@ -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<PrismaClient> {
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
}

View File

@ -35,5 +35,12 @@
"pattern": "www.quittraq.com/*",
"zone_name": "quittraq.com"
}
]
],
// Observability
"observability": {
"logs": {
"enabled": false,
"invocation_logs": true
}
}
}