496 lines
16 KiB
TypeScript
496 lines
16 KiB
TypeScript
// 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); }
|
|
|
|
// Explicit checks for usage times
|
|
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);
|
|
|
|
if (updates.length > 1) { // At least updatedAt is always there
|
|
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;
|
|
frequency: string;
|
|
hourlyStart: string | null;
|
|
hourlyEnd: string | null;
|
|
timezone: 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,
|
|
frequency: string = 'daily',
|
|
hourlyStart: string = '09:00',
|
|
hourlyEnd: string = '21:00',
|
|
timezone: string = 'UTC'
|
|
): 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 = ?, frequency = ?, hourlyStart = ?, hourlyEnd = ?, timezone = ?, updatedAt = ? WHERE userId = ?'
|
|
).bind(enabled ? 1 : 0, reminderTime, frequency, hourlyStart, hourlyEnd, timezone, now, userId).run();
|
|
} else {
|
|
await db.prepare(
|
|
`INSERT INTO ReminderSettings (id, userId, enabled, reminderTime, frequency, hourlyStart, hourlyEnd, timezone, createdAt, updatedAt)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
).bind(id, userId, enabled ? 1 : 0, reminderTime, frequency, hourlyStart, hourlyEnd, timezone, now, now).run();
|
|
}
|
|
|
|
return getReminderSettingsD1(userId);
|
|
}
|
|
|
|
export interface ReminderUserRow {
|
|
userId: string;
|
|
reminderTime: string;
|
|
frequency: string;
|
|
hourlyStart: string | null;
|
|
hourlyEnd: string | null;
|
|
timezone: string;
|
|
lastNotifiedDate: string | null;
|
|
endpoint: string;
|
|
p256dh: string;
|
|
auth: string;
|
|
}
|
|
|
|
export async function getUsersForRemindersD1(): Promise<ReminderUserRow[]> {
|
|
const db = getD1();
|
|
if (!db) return [];
|
|
|
|
const result = await db.prepare(
|
|
`SELECT
|
|
r.userId, r.reminderTime, r.frequency, r.hourlyStart, r.hourlyEnd, r.timezone, r.lastNotifiedDate,
|
|
p.endpoint, p.p256dh, p.auth
|
|
FROM ReminderSettings r
|
|
JOIN PushSubscriptions p ON r.userId = p.userId
|
|
WHERE r.enabled = 1`
|
|
).all<ReminderUserRow>();
|
|
|
|
return result.results || [];
|
|
}
|
|
|
|
export async function updateLastNotifiedD1(userId: string, dateStr: string): Promise<void> {
|
|
const db = getD1();
|
|
if (!db) return;
|
|
|
|
await db.prepare(
|
|
'UPDATE ReminderSettings SET lastNotifiedDate = ?, updatedAt = ? WHERE userId = ?'
|
|
).bind(dateStr, new Date().toISOString(), userId).run();
|
|
}
|
|
|
|
// ============ 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);
|
|
}
|
|
// ============ PUSH SUBSCRIPTIONS ============
|
|
|
|
export interface PushSubscriptionRow {
|
|
id: string;
|
|
userId: string;
|
|
endpoint: string;
|
|
p256dh: string;
|
|
auth: string;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
export async function getPushSubscriptionD1(userId: string): Promise<PushSubscriptionRow | null> {
|
|
const db = getD1();
|
|
if (!db) return null;
|
|
|
|
const result = await db.prepare(
|
|
'SELECT * FROM PushSubscriptions WHERE userId = ?'
|
|
).bind(userId).first<PushSubscriptionRow>();
|
|
|
|
return result;
|
|
}
|
|
|
|
export async function upsertPushSubscriptionD1(
|
|
userId: string,
|
|
endpoint: string,
|
|
p256dh: string,
|
|
auth: string
|
|
): Promise<PushSubscriptionRow | null> {
|
|
const db = getD1();
|
|
if (!db) return null;
|
|
|
|
const existing = await getPushSubscriptionD1(userId);
|
|
const now = new Date().toISOString();
|
|
const id = existing?.id || crypto.randomUUID();
|
|
|
|
if (existing) {
|
|
await db.prepare(
|
|
'UPDATE PushSubscriptions SET endpoint = ?, p256dh = ?, auth = ?, updatedAt = ? WHERE userId = ?'
|
|
).bind(endpoint, p256dh, auth, now, userId).run();
|
|
} else {
|
|
await db.prepare(
|
|
`INSERT INTO PushSubscriptions (id, userId, endpoint, p256dh, auth, createdAt, updatedAt)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
|
).bind(id, userId, endpoint, p256dh, auth, now, now).run();
|
|
}
|
|
|
|
return getPushSubscriptionD1(userId);
|
|
}
|
|
|
|
// ============ MOOD TRACKER ============
|
|
|
|
export interface MoodEntryRow {
|
|
id: string;
|
|
userId: string;
|
|
mood: string;
|
|
score: number;
|
|
date: string;
|
|
comment: string | null;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
export async function getMoodEntriesD1(userId: string, limit: number = 50): Promise<MoodEntryRow[]> {
|
|
const db = getD1();
|
|
if (!db) return [];
|
|
|
|
const result = await db.prepare(
|
|
'SELECT * FROM MoodEntry WHERE userId = ? ORDER BY date DESC, createdAt DESC LIMIT ?'
|
|
).bind(userId, limit).all<MoodEntryRow>();
|
|
|
|
return result.results || [];
|
|
}
|
|
|
|
export async function saveMoodEntryD1(
|
|
userId: string,
|
|
mood: string,
|
|
score: number,
|
|
date: string,
|
|
comment?: string | null
|
|
): Promise<MoodEntryRow | null> {
|
|
const db = getD1();
|
|
if (!db) return null;
|
|
|
|
const now = new Date().toISOString();
|
|
const id = crypto.randomUUID();
|
|
|
|
// Mood tracking is flexible, multiple entries per day are allowed
|
|
await db.prepare(
|
|
`INSERT INTO MoodEntry (id, userId, mood, score, date, comment, createdAt, updatedAt)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
|
).bind(id, userId, mood, score, date, comment ?? null, now, now).run();
|
|
|
|
return {
|
|
id,
|
|
userId,
|
|
mood,
|
|
score,
|
|
date,
|
|
comment: comment ?? null,
|
|
createdAt: now,
|
|
updatedAt: now
|
|
};
|
|
}
|