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:
parent
14c45eeb24
commit
bf9da84553
@ -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;
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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
329
src/lib/d1.ts
Normal 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
17
src/lib/date-utils.ts
Normal 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());
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -35,5 +35,12 @@
|
||||
"pattern": "www.quittraq.com/*",
|
||||
"zone_name": "quittraq.com"
|
||||
}
|
||||
]
|
||||
],
|
||||
// Observability
|
||||
"observability": {
|
||||
"logs": {
|
||||
"enabled": false,
|
||||
"invocation_logs": true
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user