feat: Add D1 database integration with proper Cloudflare Workers support
- Update prisma.ts to use getPrismaWithD1() with getCloudflareContext() - Update all API routes to use async D1 connection - Add open-next.config.ts for Cloudflare Workers deployment - Add wrangler.jsonc with D1 binding and custom domain routes - Fix TypeScript type errors in API routes and storage - Add @workos-inc/authkit-nextjs dependency - Remove incompatible prisma.config.ts
This commit is contained in:
parent
92c710287b
commit
14c45eeb24
4
.gitignore
vendored
4
.gitignore
vendored
@ -44,3 +44,7 @@ next-env.d.ts
|
|||||||
# database
|
# database
|
||||||
*.db
|
*.db
|
||||||
*.db-journal
|
*.db-journal
|
||||||
|
|
||||||
|
# cloudflare
|
||||||
|
.open-next/
|
||||||
|
.wrangler/
|
||||||
|
|||||||
10
env.d.ts
vendored
Normal file
10
env.d.ts
vendored
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
/// <reference types="@cloudflare/workers-types" />
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface CloudflareEnv {
|
||||||
|
DB: D1Database;
|
||||||
|
ASSETS: Fetcher;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { };
|
||||||
@ -1,7 +1,8 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
// Required for OpenNext/Cloudflare Workers
|
||||||
|
serverExternalPackages: ["@prisma/client"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
28
open-next.config.ts
Normal file
28
open-next.config.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import type { OpenNextConfig } from "@opennextjs/cloudflare";
|
||||||
|
|
||||||
|
const config: OpenNextConfig = {
|
||||||
|
default: {
|
||||||
|
override: {
|
||||||
|
wrapper: "cloudflare-node",
|
||||||
|
converter: "edge",
|
||||||
|
proxyExternalRequest: "fetch",
|
||||||
|
incrementalCache: "dummy",
|
||||||
|
tagCache: "dummy",
|
||||||
|
queue: "dummy",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
edgeExternals: ["node:crypto"],
|
||||||
|
middleware: {
|
||||||
|
external: true,
|
||||||
|
override: {
|
||||||
|
wrapper: "cloudflare-edge",
|
||||||
|
converter: "edge",
|
||||||
|
proxyExternalRequest: "fetch",
|
||||||
|
incrementalCache: "dummy",
|
||||||
|
tagCache: "dummy",
|
||||||
|
queue: "dummy",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
16
package.json
16
package.json
@ -7,10 +7,18 @@
|
|||||||
"build": "prisma generate && next build",
|
"build": "prisma generate && next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"postinstall": "prisma generate"
|
"postinstall": "prisma generate",
|
||||||
|
"build:worker": "opennextjs-cloudflare build",
|
||||||
|
"dev:worker": "wrangler dev",
|
||||||
|
"deploy": "bun run build:worker && wrangler deploy",
|
||||||
|
"d1:migrate": "wrangler d1 migrations apply quit-smoking-db --local",
|
||||||
|
"d1:migrate:prod": "wrangler d1 migrations apply quit-smoking-db --remote"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@opennextjs/cloudflare": "^1.1.1",
|
||||||
|
"@prisma/adapter-d1": "^5.22.0",
|
||||||
"@prisma/client": "5",
|
"@prisma/client": "5",
|
||||||
|
"@workos-inc/authkit-nextjs": "^0.16.0",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
@ -33,6 +41,7 @@
|
|||||||
"tailwind-merge": "^3.4.0"
|
"tailwind-merge": "^3.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@cloudflare/workers-types": "^4.20250121.0",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
@ -41,7 +50,8 @@
|
|||||||
"eslint-config-next": "16.1.4",
|
"eslint-config-next": "16.1.4",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5"
|
"typescript": "^5",
|
||||||
|
"wrangler": "^4"
|
||||||
},
|
},
|
||||||
"ignoreScripts": [
|
"ignoreScripts": [
|
||||||
"sharp",
|
"sharp",
|
||||||
@ -51,4 +61,4 @@
|
|||||||
"sharp",
|
"sharp",
|
||||||
"unrs-resolver"
|
"unrs-resolver"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -1,14 +0,0 @@
|
|||||||
// This file was generated by Prisma, and assumes you have installed the following:
|
|
||||||
// npm install --save-dev prisma dotenv
|
|
||||||
import "dotenv/config";
|
|
||||||
import { defineConfig } from "prisma/config";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
schema: "prisma/schema.prisma",
|
|
||||||
migrations: {
|
|
||||||
path: "prisma/migrations",
|
|
||||||
},
|
|
||||||
datasource: {
|
|
||||||
url: process.env["DATABASE_URL"],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
82
prisma/d1_schema.sql
Normal file
82
prisma/d1_schema.sql
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "UserPreferences" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"substance" TEXT NOT NULL DEFAULT 'nicotine',
|
||||||
|
"trackingStartDate" TEXT,
|
||||||
|
"hasCompletedSetup" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"dailyGoal" INTEGER,
|
||||||
|
"userName" TEXT,
|
||||||
|
"userAge" INTEGER,
|
||||||
|
"religion" TEXT,
|
||||||
|
"lastNicotineUsageTime" TEXT,
|
||||||
|
"lastWeedUsageTime" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
"quitPlanJson" TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "UsageEntry" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"date" TEXT NOT NULL,
|
||||||
|
"count" INTEGER NOT NULL,
|
||||||
|
"substance" TEXT NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "UsageEntry_userId_fkey" FOREIGN KEY ("userId") REFERENCES "UserPreferences" ("userId") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Achievement" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"badgeId" TEXT NOT NULL,
|
||||||
|
"unlockedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"substance" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "Achievement_userId_fkey" FOREIGN KEY ("userId") REFERENCES "UserPreferences" ("userId") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ReminderSettings" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"reminderTime" TEXT NOT NULL DEFAULT '09:00',
|
||||||
|
"lastNotifiedDate" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "ReminderSettings_userId_fkey" FOREIGN KEY ("userId") REFERENCES "UserPreferences" ("userId") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "SavingsConfig" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"costPerUnit" REAL NOT NULL,
|
||||||
|
"unitsPerDay" REAL NOT NULL,
|
||||||
|
"savingsGoal" REAL,
|
||||||
|
"goalName" TEXT,
|
||||||
|
"currency" TEXT NOT NULL DEFAULT 'USD',
|
||||||
|
"substance" TEXT NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "SavingsConfig_userId_fkey" FOREIGN KEY ("userId") REFERENCES "UserPreferences" ("userId") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "UserPreferences_userId_key" ON "UserPreferences"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "UsageEntry_userId_date_substance_key" ON "UsageEntry"("userId", "date", "substance");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Achievement_userId_badgeId_substance_key" ON "Achievement"("userId", "badgeId", "substance");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ReminderSettings_userId_key" ON "ReminderSettings"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "SavingsConfig_userId_key" ON "SavingsConfig"("userId");
|
||||||
|
|
||||||
@ -2,14 +2,16 @@
|
|||||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
|
previewFeatures = ["driverAdapters"]
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "sqlite"
|
provider = "sqlite"
|
||||||
url = "file:./dev.db"
|
url = env("DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
model UserPreferences {
|
model UserPreferences {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String @unique
|
userId String @unique
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { getPrismaWithD1 } from '@/lib/prisma';
|
||||||
import { getSession } from '@/lib/session';
|
import { getSession } from '@/lib/session';
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
@ -9,6 +9,7 @@ export async function GET() {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const prisma = await getPrismaWithD1();
|
||||||
const achievements = await prisma.achievement.findMany({
|
const achievements = await prisma.achievement.findMany({
|
||||||
where: { userId: session.user.id },
|
where: { userId: session.user.id },
|
||||||
orderBy: { unlockedAt: 'desc' },
|
orderBy: { unlockedAt: 'desc' },
|
||||||
@ -34,13 +35,15 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json() as { badgeId?: string; substance?: string };
|
||||||
const { badgeId, substance } = body;
|
const { badgeId, substance } = body;
|
||||||
|
|
||||||
if (!badgeId || !substance) {
|
if (!badgeId || !substance) {
|
||||||
return NextResponse.json({ error: 'Missing badgeId or substance' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing badgeId or substance' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const prisma = await getPrismaWithD1();
|
||||||
|
|
||||||
// Check if achievement already exists
|
// Check if achievement already exists
|
||||||
const existing = await prisma.achievement.findUnique({
|
const existing = await prisma.achievement.findUnique({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { getPrismaWithD1 } from '@/lib/prisma';
|
||||||
import { getSession } from '@/lib/session';
|
import { getSession } from '@/lib/session';
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
@ -9,6 +9,7 @@ export async function GET() {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const prisma = await getPrismaWithD1();
|
||||||
const preferences = await prisma.userPreferences.findUnique({
|
const preferences = await prisma.userPreferences.findUnique({
|
||||||
where: { userId: session.user.id },
|
where: { userId: session.user.id },
|
||||||
});
|
});
|
||||||
@ -50,7 +51,18 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json() as {
|
||||||
|
substance?: string;
|
||||||
|
trackingStartDate?: string;
|
||||||
|
hasCompletedSetup?: boolean;
|
||||||
|
dailyGoal?: number;
|
||||||
|
quitPlan?: unknown;
|
||||||
|
userName?: string;
|
||||||
|
userAge?: number;
|
||||||
|
religion?: string;
|
||||||
|
lastNicotineUsageTime?: string;
|
||||||
|
lastWeedUsageTime?: string;
|
||||||
|
};
|
||||||
const {
|
const {
|
||||||
substance,
|
substance,
|
||||||
trackingStartDate,
|
trackingStartDate,
|
||||||
@ -64,6 +76,7 @@ export async function POST(request: NextRequest) {
|
|||||||
lastWeedUsageTime
|
lastWeedUsageTime
|
||||||
} = body;
|
} = body;
|
||||||
|
|
||||||
|
const prisma = await getPrismaWithD1();
|
||||||
const preferences = await prisma.userPreferences.upsert({
|
const preferences = await prisma.userPreferences.upsert({
|
||||||
where: { userId: session.user.id },
|
where: { userId: session.user.id },
|
||||||
update: {
|
update: {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { getPrismaWithD1 } from '@/lib/prisma';
|
||||||
import { getSession } from '@/lib/session';
|
import { getSession } from '@/lib/session';
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
@ -9,6 +9,7 @@ export async function GET() {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const prisma = await getPrismaWithD1();
|
||||||
const settings = await prisma.reminderSettings.findUnique({
|
const settings = await prisma.reminderSettings.findUnique({
|
||||||
where: { userId: session.user.id },
|
where: { userId: session.user.id },
|
||||||
});
|
});
|
||||||
@ -37,9 +38,10 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json() as { enabled?: boolean; reminderTime?: string };
|
||||||
const { enabled, reminderTime } = body;
|
const { enabled, reminderTime } = body;
|
||||||
|
|
||||||
|
const prisma = await getPrismaWithD1();
|
||||||
const settings = await prisma.reminderSettings.upsert({
|
const settings = await prisma.reminderSettings.upsert({
|
||||||
where: { userId: session.user.id },
|
where: { userId: session.user.id },
|
||||||
update: {
|
update: {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { getPrismaWithD1 } from '@/lib/prisma';
|
||||||
import { getSession } from '@/lib/session';
|
import { getSession } from '@/lib/session';
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
@ -9,6 +9,7 @@ export async function GET() {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const prisma = await getPrismaWithD1();
|
||||||
const config = await prisma.savingsConfig.findUnique({
|
const config = await prisma.savingsConfig.findUnique({
|
||||||
where: { userId: session.user.id },
|
where: { userId: session.user.id },
|
||||||
});
|
});
|
||||||
@ -38,13 +39,21 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json() as {
|
||||||
|
costPerUnit?: number;
|
||||||
|
unitsPerDay?: number;
|
||||||
|
savingsGoal?: number;
|
||||||
|
goalName?: string;
|
||||||
|
currency?: string;
|
||||||
|
substance?: string;
|
||||||
|
};
|
||||||
const { costPerUnit, unitsPerDay, savingsGoal, goalName, currency, substance } = body;
|
const { costPerUnit, unitsPerDay, savingsGoal, goalName, currency, substance } = body;
|
||||||
|
|
||||||
if (costPerUnit === undefined || unitsPerDay === undefined || !substance) {
|
if (costPerUnit === undefined || unitsPerDay === undefined || !substance) {
|
||||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const prisma = await getPrismaWithD1();
|
||||||
const config = await prisma.savingsConfig.upsert({
|
const config = await prisma.savingsConfig.upsert({
|
||||||
where: { userId: session.user.id },
|
where: { userId: session.user.id },
|
||||||
update: {
|
update: {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { getPrismaWithD1 } from '@/lib/prisma';
|
||||||
import { getSession } from '@/lib/session';
|
import { getSession } from '@/lib/session';
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
@ -9,6 +9,7 @@ export async function GET() {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const prisma = await getPrismaWithD1();
|
||||||
const entries = await prisma.usageEntry.findMany({
|
const entries = await prisma.usageEntry.findMany({
|
||||||
where: { userId: session.user.id },
|
where: { userId: session.user.id },
|
||||||
orderBy: { date: 'desc' },
|
orderBy: { date: 'desc' },
|
||||||
@ -34,16 +35,22 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json() as { date?: string; count?: number; substance?: string };
|
||||||
const { date, count, substance } = body;
|
const { date, count, substance } = body;
|
||||||
|
|
||||||
|
if (!date || count === undefined || !substance) {
|
||||||
|
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const prisma = await getPrismaWithD1();
|
||||||
|
|
||||||
// Upsert: add to existing count or create new entry
|
// Upsert: add to existing count or create new entry
|
||||||
const existing = await prisma.usageEntry.findUnique({
|
const existing = await prisma.usageEntry.findUnique({
|
||||||
where: {
|
where: {
|
||||||
userId_date_substance: {
|
userId_date_substance: {
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
date,
|
date: date,
|
||||||
substance,
|
substance: substance,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -62,9 +69,9 @@ export async function POST(request: NextRequest) {
|
|||||||
const created = await prisma.usageEntry.create({
|
const created = await prisma.usageEntry.create({
|
||||||
data: {
|
data: {
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
date,
|
date: date,
|
||||||
count,
|
count: count,
|
||||||
substance,
|
substance: substance,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@ -86,24 +93,30 @@ export async function PUT(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json() as { date?: string; count?: number; substance?: string };
|
||||||
const { date, count, substance } = body;
|
const { date, count, substance } = body;
|
||||||
|
|
||||||
|
if (!date || count === undefined || !substance) {
|
||||||
|
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const prisma = await getPrismaWithD1();
|
||||||
|
|
||||||
// Set the exact count (replace, not add)
|
// Set the exact count (replace, not add)
|
||||||
const entry = await prisma.usageEntry.upsert({
|
const entry = await prisma.usageEntry.upsert({
|
||||||
where: {
|
where: {
|
||||||
userId_date_substance: {
|
userId_date_substance: {
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
date,
|
date: date,
|
||||||
substance,
|
substance: substance,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
update: { count },
|
update: { count: count },
|
||||||
create: {
|
create: {
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
date,
|
date: date,
|
||||||
count,
|
count: count,
|
||||||
substance,
|
substance: substance,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -133,6 +146,8 @@ export async function DELETE(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Missing date or substance' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing date or substance' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const prisma = await getPrismaWithD1();
|
||||||
|
|
||||||
await prisma.usageEntry.deleteMany({
|
await prisma.usageEntry.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
|
|||||||
@ -2,40 +2,63 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
getUserData,
|
getPreferences,
|
||||||
saveUserData,
|
savePreferences,
|
||||||
getUsageLogs,
|
getUsageData,
|
||||||
addUsageLog,
|
saveUsageEntry,
|
||||||
getRecentLogs,
|
getWeeklyData,
|
||||||
calculateStats,
|
calculateWeeklyAverage,
|
||||||
getQuitPlan,
|
generateQuitPlan as generateQuitPlanFn,
|
||||||
generateQuitPlan,
|
hasOneWeekOfData,
|
||||||
getCurrentWeek,
|
fetchPreferences,
|
||||||
canGeneratePlan,
|
fetchUsageData,
|
||||||
needsCheckIn as checkNeedsCheckIn,
|
UserPreferences,
|
||||||
|
UsageEntry,
|
||||||
|
QuitPlan,
|
||||||
} from "@/lib/storage";
|
} from "@/lib/storage";
|
||||||
import { UserData, UsageLog, QuitPlan, SubstanceType } from "@/types";
|
|
||||||
|
type SubstanceType = 'nicotine' | 'weed';
|
||||||
|
|
||||||
|
interface UserData {
|
||||||
|
substanceType: SubstanceType;
|
||||||
|
stayLoggedIn: boolean;
|
||||||
|
onboardingComplete: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export function useUserData() {
|
export function useUserData() {
|
||||||
const [userData, setUserData] = useState<UserData | null>(null);
|
const [userData, setUserData] = useState<UserData | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setUserData(getUserData());
|
const loadData = async () => {
|
||||||
setIsLoading(false);
|
const prefs = await fetchPreferences();
|
||||||
|
setUserData({
|
||||||
|
substanceType: prefs.substance,
|
||||||
|
stayLoggedIn: true,
|
||||||
|
onboardingComplete: prefs.hasCompletedSetup,
|
||||||
|
});
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
loadData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const updateUserData = useCallback((data: Partial<UserData>) => {
|
const updateUserData = useCallback((data: Partial<UserData>) => {
|
||||||
const updated = saveUserData(data);
|
const prefs = getPreferences();
|
||||||
setUserData(updated);
|
const updated: UserPreferences = {
|
||||||
|
...prefs,
|
||||||
|
substance: data.substanceType || prefs.substance,
|
||||||
|
hasCompletedSetup: data.onboardingComplete ?? prefs.hasCompletedSetup,
|
||||||
|
};
|
||||||
|
savePreferences(updated);
|
||||||
|
setUserData((prev) => (prev ? { ...prev, ...data } : null));
|
||||||
return updated;
|
return updated;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const completeOnboarding = useCallback(
|
const completeOnboarding = useCallback(
|
||||||
(substanceType: SubstanceType, stayLoggedIn: boolean) => {
|
(substanceType: SubstanceType, _stayLoggedIn: boolean) => {
|
||||||
return updateUserData({
|
return updateUserData({
|
||||||
substanceType,
|
substanceType,
|
||||||
stayLoggedIn,
|
stayLoggedIn: true,
|
||||||
onboardingComplete: true,
|
onboardingComplete: true,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@ -52,32 +75,52 @@ export function useUserData() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useUsageLogs() {
|
export function useUsageLogs() {
|
||||||
const [logs, setLogs] = useState<UsageLog[]>([]);
|
const [logs, setLogs] = useState<UsageEntry[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
const refreshLogs = useCallback(() => {
|
const refreshLogs = useCallback(async () => {
|
||||||
setLogs(getUsageLogs());
|
const data = await fetchUsageData();
|
||||||
|
setLogs(data);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshLogs();
|
refreshLogs().then(() => setIsLoading(false));
|
||||||
setIsLoading(false);
|
|
||||||
}, [refreshLogs]);
|
}, [refreshLogs]);
|
||||||
|
|
||||||
const logUsage = useCallback(
|
const logUsage = useCallback(
|
||||||
(puffs: number, date?: string) => {
|
(puffs: number, date?: string) => {
|
||||||
const newLog = addUsageLog(puffs, date);
|
const prefs = getPreferences();
|
||||||
|
const logDate = date || new Date().toISOString().split("T")[0];
|
||||||
|
const newEntry: UsageEntry = {
|
||||||
|
date: logDate,
|
||||||
|
count: puffs,
|
||||||
|
substance: prefs.substance,
|
||||||
|
};
|
||||||
|
saveUsageEntry(newEntry);
|
||||||
refreshLogs();
|
refreshLogs();
|
||||||
return newLog;
|
return newEntry;
|
||||||
},
|
},
|
||||||
[refreshLogs]
|
[refreshLogs]
|
||||||
);
|
);
|
||||||
|
|
||||||
const getRecent = useCallback((days: number = 7) => {
|
const getRecent = useCallback((days: number = 7) => {
|
||||||
return getRecentLogs(days);
|
const data = getUsageData();
|
||||||
|
const cutoff = new Date();
|
||||||
|
cutoff.setDate(cutoff.getDate() - days);
|
||||||
|
return data.filter((e) => new Date(e.date) >= cutoff);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const stats = calculateStats(getRecentLogs(7));
|
const calculateStats = (entries: UsageEntry[]) => {
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return { totalPuffs: 0, averagePuffs: 0, daysTracked: 0 };
|
||||||
|
}
|
||||||
|
const totalPuffs = entries.reduce((sum, e) => sum + e.count, 0);
|
||||||
|
const daysTracked = new Set(entries.map((e) => e.date)).size;
|
||||||
|
const averagePuffs = Math.round(totalPuffs / daysTracked);
|
||||||
|
return { totalPuffs, averagePuffs, daysTracked };
|
||||||
|
};
|
||||||
|
|
||||||
|
const stats = calculateStats(getRecent(7));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
logs,
|
logs,
|
||||||
@ -93,30 +136,44 @@ export function useQuitPlan() {
|
|||||||
const [plan, setPlan] = useState<QuitPlan | null>(null);
|
const [plan, setPlan] = useState<QuitPlan | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
const refreshPlan = useCallback(() => {
|
const refreshPlan = useCallback(async () => {
|
||||||
setPlan(getQuitPlan());
|
const prefs = await fetchPreferences();
|
||||||
|
setPlan(prefs.quitPlan);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshPlan();
|
refreshPlan().then(() => setIsLoading(false));
|
||||||
setIsLoading(false);
|
|
||||||
}, [refreshPlan]);
|
}, [refreshPlan]);
|
||||||
|
|
||||||
const createPlan = useCallback(() => {
|
const createPlan = useCallback(() => {
|
||||||
const recentLogs = getRecentLogs(7);
|
const prefs = getPreferences();
|
||||||
const { averagePuffs } = calculateStats(recentLogs);
|
const avgPuffs = calculateWeeklyAverage(prefs.substance);
|
||||||
|
|
||||||
if (averagePuffs === 0) {
|
if (avgPuffs === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newPlan = generateQuitPlan(averagePuffs);
|
const newPlan = generateQuitPlanFn(prefs.substance);
|
||||||
|
const updatedPrefs = { ...prefs, quitPlan: newPlan };
|
||||||
|
savePreferences(updatedPrefs);
|
||||||
setPlan(newPlan);
|
setPlan(newPlan);
|
||||||
return newPlan;
|
return newPlan;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const getCurrentWeek = (quitPlan: QuitPlan): number => {
|
||||||
|
const startDate = new Date(quitPlan.startDate);
|
||||||
|
const today = new Date();
|
||||||
|
const weekNumber = Math.floor(
|
||||||
|
(today.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24 * 7)
|
||||||
|
);
|
||||||
|
return Math.min(weekNumber, quitPlan.weeklyTargets.length - 1);
|
||||||
|
};
|
||||||
|
|
||||||
const currentWeek = plan ? getCurrentWeek(plan) : 0;
|
const currentWeek = plan ? getCurrentWeek(plan) : 0;
|
||||||
const { canGenerate, daysTracked } = canGeneratePlan();
|
const prefs = getPreferences();
|
||||||
|
const canGenerate = hasOneWeekOfData(prefs.substance);
|
||||||
|
const weeklyData = getWeeklyData(prefs.substance);
|
||||||
|
const daysTracked = new Set(weeklyData.map((e) => e.date)).size;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
plan,
|
plan,
|
||||||
@ -133,7 +190,11 @@ export function useCheckIn() {
|
|||||||
const [needsCheckIn, setNeedsCheckIn] = useState(false);
|
const [needsCheckIn, setNeedsCheckIn] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setNeedsCheckIn(checkNeedsCheckIn());
|
// Check if user needs to check in today
|
||||||
|
const today = new Date().toISOString().split("T")[0];
|
||||||
|
const data = getUsageData();
|
||||||
|
const todayEntry = data.find((e) => e.date === today);
|
||||||
|
setNeedsCheckIn(!todayEntry);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const markCheckedIn = useCallback(() => {
|
const markCheckedIn = useCallback(() => {
|
||||||
|
|||||||
@ -1,9 +1,39 @@
|
|||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { PrismaD1 } from '@prisma/adapter-d1';
|
||||||
|
|
||||||
|
// For local development fallback
|
||||||
const globalForPrisma = globalThis as unknown as {
|
const globalForPrisma = globalThis as unknown as {
|
||||||
prisma: PrismaClient | undefined;
|
prisma: PrismaClient | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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> {
|
||||||
|
// Try to get Cloudflare context (only works in Workers environment)
|
||||||
|
try {
|
||||||
|
const { getCloudflareContext } = await import('@opennextjs/cloudflare');
|
||||||
|
const { env } = await getCloudflareContext();
|
||||||
|
|
||||||
|
if (env?.DB) {
|
||||||
|
const adapter = new PrismaD1(env.DB);
|
||||||
|
return new PrismaClient({ adapter });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not in Cloudflare Workers environment, fall back to local client
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for local development
|
||||||
|
if (!globalForPrisma.prisma) {
|
||||||
|
globalForPrisma.prisma = new PrismaClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
return globalForPrisma.prisma;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy synchronous export for backward compatibility in local dev
|
||||||
|
// NOTE: This does NOT work with D1 in production - use getPrismaWithD1() instead
|
||||||
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
|
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
|||||||
@ -134,7 +134,7 @@ export async function fetchPreferences(): Promise<UserPreferences> {
|
|||||||
console.error('Failed to fetch preferences');
|
console.error('Failed to fetch preferences');
|
||||||
return defaultPreferences;
|
return defaultPreferences;
|
||||||
}
|
}
|
||||||
const data = await response.json();
|
const data = await response.json() as UserPreferences;
|
||||||
preferencesCache = data;
|
preferencesCache = data;
|
||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -166,7 +166,7 @@ export async function fetchUsageData(): Promise<UsageEntry[]> {
|
|||||||
console.error('Failed to fetch usage data');
|
console.error('Failed to fetch usage data');
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const data = await response.json();
|
const data = await response.json() as UsageEntry[];
|
||||||
usageDataCache = data;
|
usageDataCache = data;
|
||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -226,7 +226,7 @@ export async function fetchAchievements(): Promise<Achievement[]> {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch('/api/achievements');
|
const response = await fetch('/api/achievements');
|
||||||
if (!response.ok) return [];
|
if (!response.ok) return [];
|
||||||
const data = await response.json();
|
const data = await response.json() as Achievement[];
|
||||||
achievementsCache = data;
|
achievementsCache = data;
|
||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -246,7 +246,7 @@ export async function unlockAchievement(
|
|||||||
body: JSON.stringify({ badgeId, substance }),
|
body: JSON.stringify({ badgeId, substance }),
|
||||||
});
|
});
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json() as { badgeId: string; unlockedAt: string; substance: 'nicotine' | 'weed' | 'both'; alreadyUnlocked?: boolean };
|
||||||
achievementsCache = null; // Invalidate cache
|
achievementsCache = null; // Invalidate cache
|
||||||
return {
|
return {
|
||||||
achievement: {
|
achievement: {
|
||||||
@ -275,7 +275,7 @@ export async function fetchReminderSettings(): Promise<ReminderSettings> {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch('/api/reminders');
|
const response = await fetch('/api/reminders');
|
||||||
if (!response.ok) return { enabled: false, reminderTime: '09:00' };
|
if (!response.ok) return { enabled: false, reminderTime: '09:00' };
|
||||||
const data = await response.json();
|
const data = await response.json() as ReminderSettings;
|
||||||
reminderSettingsCache = data;
|
reminderSettingsCache = data;
|
||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -310,7 +310,7 @@ export async function fetchSavingsConfig(): Promise<SavingsConfig | null> {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch('/api/savings');
|
const response = await fetch('/api/savings');
|
||||||
if (!response.ok) return null;
|
if (!response.ok) return null;
|
||||||
const data = await response.json();
|
const data = await response.json() as SavingsConfig | null;
|
||||||
savingsConfigCache = data;
|
savingsConfigCache = data;
|
||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
39
wrangler.jsonc
Normal file
39
wrangler.jsonc
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
// Worker name - change this to your preferred name
|
||||||
|
"name": "quit-smoking-app",
|
||||||
|
// Use the OpenNext preset for Next.js on Cloudflare Workers
|
||||||
|
"main": ".open-next/worker.js",
|
||||||
|
// Compatibility settings for Node.js APIs
|
||||||
|
"compatibility_date": "2024-09-23",
|
||||||
|
"compatibility_flags": [
|
||||||
|
"nodejs_compat"
|
||||||
|
],
|
||||||
|
// Set up route matching for static assets
|
||||||
|
"assets": {
|
||||||
|
"directory": ".open-next/assets",
|
||||||
|
"binding": "ASSETS"
|
||||||
|
},
|
||||||
|
// D1 Database binding
|
||||||
|
"d1_databases": [
|
||||||
|
{
|
||||||
|
"binding": "DB",
|
||||||
|
"database_name": "quit-smoking-db",
|
||||||
|
"database_id": "1fca511a-cf91-4fde-854b-561ed92abfa8"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
// Environment variables
|
||||||
|
"vars": {
|
||||||
|
"NODE_ENV": "production"
|
||||||
|
},
|
||||||
|
// Custom domain
|
||||||
|
"routes": [
|
||||||
|
{
|
||||||
|
"pattern": "quittraq.com/*",
|
||||||
|
"zone_name": "quittraq.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pattern": "www.quittraq.com/*",
|
||||||
|
"zone_name": "quittraq.com"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user