migrate: replace pg with Prisma ORM
- Add prisma/schema.prisma with Project/Task models, enums, and relations - Create src/lib/db.ts singleton Prisma client - Refactor all 5 API routes to use Prisma queries - Replace migrate.ts with seed.ts for initial data - Update Dockerfile for Prisma lifecycle (copy generated client) - Update tsconfig.json with @/generated/* path alias - Remove pg and @types/pg dependencies - Add prisma.config.ts for Prisma 6 config - Update .gitignore for generated Prisma client
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -39,3 +39,7 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
# Prisma
|
||||||
|
!prisma/migrations/**
|
||||||
|
src/generated/
|
||||||
|
|||||||
22
Dockerfile
22
Dockerfile
@@ -12,6 +12,9 @@ RUN npm ci
|
|||||||
# Copy source code
|
# Copy source code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# Generate Prisma client
|
||||||
|
RUN npx prisma generate
|
||||||
|
|
||||||
# Build the Next.js app
|
# Build the Next.js app
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
@@ -24,9 +27,8 @@ ENV NODE_ENV=production
|
|||||||
ENV HOSTNAME="0.0.0.0"
|
ENV HOSTNAME="0.0.0.0"
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
|
||||||
# Install postgres client, netcat, and tsx for migrations
|
# Install postgres client and netcat
|
||||||
RUN apk add --no-cache postgresql-client netcat-openbsd && \
|
RUN apk add --no-cache postgresql-client netcat-openbsd
|
||||||
npm install -g tsx
|
|
||||||
|
|
||||||
# Create a non-root user
|
# Create a non-root user
|
||||||
RUN addgroup --system --gid 1001 nodejs && \
|
RUN addgroup --system --gid 1001 nodejs && \
|
||||||
@@ -34,14 +36,14 @@ RUN addgroup --system --gid 1001 nodejs && \
|
|||||||
|
|
||||||
# Copy the built app from builder
|
# Copy the built app from builder
|
||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone/ ./
|
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
|
||||||
COPY --from=builder /app/package.json ./
|
COPY --from=builder /app/package.json ./
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/prisma ./prisma
|
||||||
|
# Copy generated Prisma client (includes native query engine binary)
|
||||||
|
COPY --from=builder /app/src/generated ./src/generated
|
||||||
|
|
||||||
# Copy migration script (for initial setup)
|
# Copy startup script
|
||||||
COPY --chown=nextjs:nodejs src/server/db/migrate.ts ./migrate.ts
|
|
||||||
|
|
||||||
# Create startup script
|
|
||||||
COPY start.sh /app/start.sh
|
COPY start.sh /app/start.sh
|
||||||
RUN chmod +x /app/start.sh
|
RUN chmod +x /app/start.sh
|
||||||
|
|
||||||
@@ -49,4 +51,4 @@ USER nextjs
|
|||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
CMD ["/app/start.sh"]
|
CMD ["/app/start.sh"]
|
||||||
|
|||||||
1057
package-lock.json
generated
1057
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -6,24 +6,28 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"db:generate": "prisma generate",
|
||||||
|
"db:migrate": "prisma migrate deploy",
|
||||||
|
"db:seed": "tsx src/server/db/seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@prisma/client": "^6.19.3",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"next": "16.2.4",
|
"next": "16.2.4",
|
||||||
"pg": "^8.20.0",
|
"prisma": "^6.19.3",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/pg": "^8.20.0",
|
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.4",
|
"eslint-config-next": "16.2.4",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
"tsx": "^4.19.0",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
11
prisma.config.ts
Normal file
11
prisma.config.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from "prisma/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "prisma/schema.prisma",
|
||||||
|
migrations: {
|
||||||
|
path: "prisma/migrations",
|
||||||
|
},
|
||||||
|
datasource: {
|
||||||
|
url: process.env["DATABASE_URL"] || "postgresql://vixtix:vixtix_secret@localhost:5433/vixtix",
|
||||||
|
},
|
||||||
|
});
|
||||||
76
prisma/schema.prisma
Normal file
76
prisma/schema.prisma
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
// This is your Prisma schema file,
|
||||||
|
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client"
|
||||||
|
output = "../src/generated/prisma"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Project {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
name String
|
||||||
|
description String @default("")
|
||||||
|
color String @default("#3b82f6")
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
tasks Task[]
|
||||||
|
|
||||||
|
@@index([sortOrder])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Task {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
title String
|
||||||
|
description String @default("")
|
||||||
|
completed Boolean @default(false)
|
||||||
|
priority Priority @default(medium)
|
||||||
|
dueDate DateTime? @map("due_date")
|
||||||
|
status Status @default(todo)
|
||||||
|
parentTaskId String? @map("parent_task_id") @db.Uuid
|
||||||
|
recurrenceRule Recurrence @default(none) @map("recurrence_rule")
|
||||||
|
recurrenceInterval Int @default(1) @map("recurrence_interval")
|
||||||
|
nextOccurrence DateTime? @map("next_occurrence")
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
parentTask Task? @relation("TaskChildren", fields: [parentTaskId], references: [id], onDelete: Cascade)
|
||||||
|
children Task[] @relation("TaskChildren")
|
||||||
|
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
projectId String? @map("project_id") @db.Uuid
|
||||||
|
|
||||||
|
@@index([projectId])
|
||||||
|
@@index([parentTaskId])
|
||||||
|
@@index([completed])
|
||||||
|
@@index([status])
|
||||||
|
@@index([dueDate])
|
||||||
|
@@index([recurrenceRule])
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Priority {
|
||||||
|
low
|
||||||
|
medium
|
||||||
|
high
|
||||||
|
urgent
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Status {
|
||||||
|
todo
|
||||||
|
in_progress
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Recurrence {
|
||||||
|
none
|
||||||
|
daily
|
||||||
|
weekly
|
||||||
|
biweekly
|
||||||
|
monthly
|
||||||
|
yearly
|
||||||
|
}
|
||||||
@@ -1,59 +1,34 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { Pool } from 'pg';
|
import prisma from '@/lib/db';
|
||||||
|
|
||||||
const pool = new Pool({
|
|
||||||
connectionString: process.env.DATABASE_URL || 'postgresql://vixtix:vixtix_secret@localhost:5433/vixtix',
|
|
||||||
});
|
|
||||||
|
|
||||||
function serializeRow(row: Record<string, any>): Record<string, any> {
|
|
||||||
const toCamel = (str: string) => str.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
||||||
const result: Record<string, any> = {};
|
|
||||||
for (const [key, value] of Object.entries(row)) {
|
|
||||||
const camelKey = toCamel(key);
|
|
||||||
if (value instanceof Date && !isNaN(value.getTime())) {
|
|
||||||
if (value.getUTCHours() === 0 && value.getUTCMinutes() === 0 && value.getUTCSeconds() === 0 && value.getUTCMilliseconds() === 0) {
|
|
||||||
const year = value.getUTCFullYear();
|
|
||||||
const month = String(value.getUTCMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(value.getUTCDate()).padStart(2, '0');
|
|
||||||
result[camelKey] = `${year}-${month}-${day}`;
|
|
||||||
} else {
|
|
||||||
result[camelKey] = value.toISOString();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
result[camelKey] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const projectId = searchParams.get('project');
|
const projectId = searchParams.get('project');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let whereClause = 'parent_task_id IS NULL';
|
const where: Record<string, unknown> = { parentTaskId: null };
|
||||||
let params: any[] = [];
|
if (projectId) where.projectId = projectId;
|
||||||
let paramIndex = 1;
|
|
||||||
|
|
||||||
if (projectId) {
|
const tasks = await prisma.task.findMany({
|
||||||
whereClause += ` AND project_id = $${paramIndex++}`;
|
where,
|
||||||
params.push(projectId);
|
include: {
|
||||||
}
|
project: { select: { color: true, name: true } },
|
||||||
|
},
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
const result = await pool.query(
|
const rows = tasks.map((t) => ({
|
||||||
`SELECT t.*, p.color as project_color, p.name as project_name
|
...t,
|
||||||
FROM tasks t
|
dueDate: t.dueDate ? t.dueDate.toISOString().split('T')[0] : null,
|
||||||
LEFT JOIN projects p ON t.project_id = p.id
|
nextOccurrence: t.nextOccurrence ? t.nextOccurrence.toISOString().split('T')[0] : null,
|
||||||
WHERE ${whereClause}
|
projectColor: t.project?.color,
|
||||||
ORDER BY t.sort_order`,
|
projectName: t.project?.name,
|
||||||
params
|
}));
|
||||||
);
|
|
||||||
|
|
||||||
const rows = result.rows.map(serializeRow);
|
|
||||||
const grouped = {
|
const grouped = {
|
||||||
todo: rows.filter((t: any) => t.status === 'todo'),
|
todo: rows.filter((t) => t.status === 'todo'),
|
||||||
in_progress: rows.filter((t: any) => t.status === 'in_progress'),
|
in_progress: rows.filter((t) => t.status === 'in_progress'),
|
||||||
done: rows.filter((t: any) => t.status === 'done'),
|
done: rows.filter((t) => t.status === 'done'),
|
||||||
};
|
};
|
||||||
|
|
||||||
return NextResponse.json(grouped);
|
return NextResponse.json(grouped);
|
||||||
|
|||||||
@@ -1,23 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { Pool } from 'pg';
|
import prisma from '@/lib/db';
|
||||||
|
|
||||||
const pool = new Pool({
|
|
||||||
connectionString: process.env.DATABASE_URL || 'postgresql://vixtix:vixtix_secret@localhost:5433/vixtix',
|
|
||||||
});
|
|
||||||
|
|
||||||
function serializeRow(row: Record<string, any>): Record<string, any> {
|
|
||||||
const toCamel = (str: string) => str.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
||||||
const result: Record<string, any> = {};
|
|
||||||
for (const [key, value] of Object.entries(row)) {
|
|
||||||
const camelKey = toCamel(key);
|
|
||||||
if (value instanceof Date) {
|
|
||||||
result[camelKey] = value.toISOString();
|
|
||||||
} else {
|
|
||||||
result[camelKey] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
@@ -26,13 +8,13 @@ export async function GET(
|
|||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await pool.query('SELECT * FROM projects WHERE id = $1', [id]);
|
const project = await prisma.project.findUnique({ where: { id } });
|
||||||
|
|
||||||
if (result.rows.length === 0) {
|
if (!project) {
|
||||||
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
|
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(serializeRow(result.rows[0]));
|
return NextResponse.json(project);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching project:', error);
|
console.error('Error fetching project:', error);
|
||||||
return NextResponse.json({ error: 'Failed to fetch project' }, { status: 500 });
|
return NextResponse.json({ error: 'Failed to fetch project' }, { status: 500 });
|
||||||
@@ -59,16 +41,17 @@ export async function PUT(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await pool.query(
|
const project = await prisma.project.update({
|
||||||
`UPDATE projects SET name = $1, description = $2, color = $3, sort_order = $4 WHERE id = $5 RETURNING *`,
|
where: { id },
|
||||||
[name.trim(), description?.trim() || '', color, sortOrder, id]
|
data: {
|
||||||
);
|
name: name.trim(),
|
||||||
|
description: description?.trim() || '',
|
||||||
|
color: color,
|
||||||
|
sortOrder: sortOrder,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (result.rows.length === 0) {
|
return NextResponse.json(project);
|
||||||
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(serializeRow(result.rows[0]));
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating project:', error);
|
console.error('Error updating project:', error);
|
||||||
return NextResponse.json({ error: 'Failed to update project' }, { status: 500 });
|
return NextResponse.json({ error: 'Failed to update project' }, { status: 500 });
|
||||||
@@ -82,10 +65,7 @@ export async function DELETE(
|
|||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Delete all tasks in the project first
|
await prisma.project.delete({ where: { id } });
|
||||||
await pool.query('DELETE FROM tasks WHERE project_id = $1', [id]);
|
|
||||||
// Then delete the project
|
|
||||||
await pool.query('DELETE FROM projects WHERE id = $1', [id]);
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,35 +1,12 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { Pool } from 'pg';
|
import prisma from '@/lib/db';
|
||||||
|
|
||||||
const pool = new Pool({
|
export async function GET() {
|
||||||
connectionString: process.env.DATABASE_URL || 'postgresql://vixtix:vixtix_secret@localhost:5433/vixtix',
|
|
||||||
});
|
|
||||||
|
|
||||||
function serializeRow(row: Record<string, any>): Record<string, any> {
|
|
||||||
const toCamel = (str: string) => str.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
||||||
const result: Record<string, any> = {};
|
|
||||||
for (const [key, value] of Object.entries(row)) {
|
|
||||||
const camelKey = toCamel(key);
|
|
||||||
if (value instanceof Date && !isNaN(value.getTime())) {
|
|
||||||
if (value.getUTCHours() === 0 && value.getUTCMinutes() === 0 && value.getUTCSeconds() === 0 && value.getUTCMilliseconds() === 0) {
|
|
||||||
const year = value.getUTCFullYear();
|
|
||||||
const month = String(value.getUTCMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(value.getUTCDate()).padStart(2, '0');
|
|
||||||
result[camelKey] = `${year}-${month}-${day}`;
|
|
||||||
} else {
|
|
||||||
result[camelKey] = value.toISOString();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
result[camelKey] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
try {
|
||||||
const result = await pool.query('SELECT * FROM projects ORDER BY sort_order');
|
const projects = await prisma.project.findMany({
|
||||||
return NextResponse.json(result.rows.map(serializeRow));
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
});
|
||||||
|
return NextResponse.json(projects);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching projects:', error);
|
console.error('Error fetching projects:', error);
|
||||||
return NextResponse.json({ error: 'Failed to fetch projects' }, { status: 500 });
|
return NextResponse.json({ error: 'Failed to fetch projects' }, { status: 500 });
|
||||||
@@ -50,14 +27,22 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const maxSort = await pool.query('SELECT COALESCE(MAX(sort_order), -1) as max FROM projects');
|
const maxSort = await prisma.project.aggregate({
|
||||||
const result = await pool.query(
|
_max: { sortOrder: true },
|
||||||
`INSERT INTO projects (name, description, color, sort_order) VALUES ($1, $2, $3, $4) RETURNING *`,
|
});
|
||||||
[name.trim(), description, color, (maxSort.rows[0].max ?? -1) + 1]
|
|
||||||
);
|
const project = await prisma.project.create({
|
||||||
return NextResponse.json(serializeRow(result.rows[0]), { status: 201 });
|
data: {
|
||||||
|
name: name.trim(),
|
||||||
|
description: description,
|
||||||
|
color: color,
|
||||||
|
sortOrder: (maxSort._max.sortOrder ?? -1) + 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(project, { status: 201 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating project:', error);
|
console.error('Error creating project:', error);
|
||||||
return NextResponse.json({ error: 'Failed to create project' }, { status: 500 });
|
return NextResponse.json({ error: 'Failed to create project' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { Pool } from 'pg';
|
import prisma from '@/lib/db';
|
||||||
import { addDays, addWeeks, addMonths, addYears } from 'date-fns';
|
import { addDays, addWeeks, addMonths, addYears } from 'date-fns';
|
||||||
|
|
||||||
const pool = new Pool({
|
|
||||||
connectionString: process.env.DATABASE_URL || 'postgresql://vixtix:vixtix_secret@localhost:5433/vixtix',
|
|
||||||
});
|
|
||||||
|
|
||||||
function toCamel(str: string): string {
|
|
||||||
return str.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
function serializeRow(row: Record<string, any>): Record<string, any> {
|
|
||||||
const result: Record<string, any> = {};
|
|
||||||
for (const [key, value] of Object.entries(row)) {
|
|
||||||
const camelKey = toCamel(key);
|
|
||||||
if (value instanceof Date && !isNaN(value.getTime())) {
|
|
||||||
if (value.getUTCHours() === 0 && value.getUTCMinutes() === 0 && value.getUTCSeconds() === 0 && value.getUTCMilliseconds() === 0) {
|
|
||||||
const year = value.getUTCFullYear();
|
|
||||||
const month = String(value.getUTCMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(value.getUTCDate()).padStart(2, '0');
|
|
||||||
result[camelKey] = `${year}-${month}-${day}`;
|
|
||||||
} else {
|
|
||||||
result[camelKey] = value.toISOString();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
result[camelKey] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
@@ -37,23 +9,26 @@ export async function GET(
|
|||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const taskResult = await pool.query(
|
const task = await prisma.task.findUnique({
|
||||||
'SELECT * FROM tasks WHERE id = $1',
|
where: { id },
|
||||||
[id]
|
include: {
|
||||||
);
|
children: { orderBy: { sortOrder: 'asc' } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (taskResult.rows.length === 0) {
|
if (!task) {
|
||||||
return NextResponse.json({ error: 'Task not found' }, { status: 404 });
|
return NextResponse.json({ error: 'Task not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const subtaskResult = await pool.query(
|
|
||||||
'SELECT * FROM tasks WHERE parent_task_id = $1 ORDER BY sort_order',
|
|
||||||
[id]
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
...serializeRow(taskResult.rows[0]),
|
...task,
|
||||||
subtasks: subtaskResult.rows.map(serializeRow),
|
dueDate: task.dueDate ? task.dueDate.toISOString().split('T')[0] : null,
|
||||||
|
nextOccurrence: task.nextOccurrence ? task.nextOccurrence.toISOString().split('T')[0] : null,
|
||||||
|
subtasks: task.children.map((c) => ({
|
||||||
|
...c,
|
||||||
|
dueDate: c.dueDate ? c.dueDate.toISOString().split('T')[0] : null,
|
||||||
|
nextOccurrence: c.nextOccurrence ? c.nextOccurrence.toISOString().split('T')[0] : null,
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching task:', error);
|
console.error('Error fetching task:', error);
|
||||||
@@ -74,46 +49,33 @@ export async function PUT(
|
|||||||
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
|
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const { title, description, projectId, priority, dueDate, status, recurrenceRule, recurrenceInterval, nextOccurrence } = body;
|
||||||
title,
|
|
||||||
description,
|
|
||||||
projectId,
|
|
||||||
priority,
|
|
||||||
dueDate,
|
|
||||||
status,
|
|
||||||
recurrenceRule,
|
|
||||||
recurrenceInterval,
|
|
||||||
nextOccurrence,
|
|
||||||
} = body;
|
|
||||||
|
|
||||||
if (!title?.trim()) {
|
if (!title?.trim()) {
|
||||||
return NextResponse.json({ error: 'Title is required' }, { status: 400 });
|
return NextResponse.json({ error: 'Title is required' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await pool.query(
|
const task = await prisma.task.update({
|
||||||
`UPDATE tasks SET title = $1, description = $2, project_id = $3, priority = $4, due_date = $5, status = $6, recurrence_rule = $7, recurrence_interval = $8, next_occurrence = $9, updated_at = $10
|
where: { id },
|
||||||
WHERE id = $11 RETURNING *`,
|
data: {
|
||||||
[
|
title: title.trim(),
|
||||||
title.trim(),
|
description: description?.trim() || '',
|
||||||
description?.trim() || '',
|
projectId: projectId || undefined,
|
||||||
projectId,
|
priority: priority,
|
||||||
priority,
|
dueDate: dueDate ? new Date(dueDate) : undefined,
|
||||||
dueDate || null,
|
status: status,
|
||||||
status,
|
recurrenceRule: recurrenceRule,
|
||||||
recurrenceRule,
|
recurrenceInterval: recurrenceInterval,
|
||||||
recurrenceInterval,
|
nextOccurrence: nextOccurrence ? new Date(nextOccurrence) : undefined,
|
||||||
nextOccurrence || null,
|
},
|
||||||
new Date(),
|
});
|
||||||
id,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.rows.length === 0) {
|
return NextResponse.json({
|
||||||
return NextResponse.json({ error: 'Task not found' }, { status: 404 });
|
...task,
|
||||||
}
|
dueDate: task.dueDate ? task.dueDate.toISOString().split('T')[0] : null,
|
||||||
|
nextOccurrence: task.nextOccurrence ? task.nextOccurrence.toISOString().split('T')[0] : null,
|
||||||
return NextResponse.json(serializeRow(result.rows[0]));
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating task:', error);
|
console.error('Error updating task:', error);
|
||||||
return NextResponse.json({ error: 'Failed to update task' }, { status: 500 });
|
return NextResponse.json({ error: 'Failed to update task' }, { status: 500 });
|
||||||
@@ -127,8 +89,8 @@ export async function DELETE(
|
|||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await pool.query('DELETE FROM tasks WHERE parent_task_id = $1', [id]);
|
await prisma.task.deleteMany({ where: { parentTaskId: id } });
|
||||||
await pool.query('DELETE FROM tasks WHERE id = $1', [id]);
|
await prisma.task.delete({ where: { id } });
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -145,48 +107,48 @@ export async function PATCH(
|
|||||||
const { action } = await request.json();
|
const { action } = await request.json();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const taskResult = await pool.query('SELECT * FROM tasks WHERE id = $1', [id]);
|
const task = await prisma.task.findUnique({ where: { id } });
|
||||||
|
|
||||||
if (taskResult.rows.length === 0) {
|
if (!task) {
|
||||||
return NextResponse.json({ error: 'Task not found' }, { status: 404 });
|
return NextResponse.json({ error: 'Task not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const task = taskResult.rows[0];
|
|
||||||
|
|
||||||
if (action === 'toggle-done') {
|
if (action === 'toggle-done') {
|
||||||
// If completing a recurring task, create the next occurrence
|
// If completing a recurring task, create the next occurrence
|
||||||
if (!task.completed && task.recurrence_rule !== 'none' && task.due_date) {
|
if (!task.completed && task.recurrenceRule !== 'none' && task.dueDate) {
|
||||||
const currentDate = new Date(task.due_date);
|
const currentDate = new Date(task.dueDate);
|
||||||
const nextDate = getNextOccurrence(currentDate, task.recurrence_rule, task.recurrence_interval);
|
const nextDate = getNextOccurrence(currentDate, task.recurrenceRule, task.recurrenceInterval);
|
||||||
// Convert to "YYYY-MM-DD" string for DATE column
|
|
||||||
const nextDateStr = nextDate.toISOString().split('T')[0];
|
await prisma.task.create({
|
||||||
|
data: {
|
||||||
await pool.query(
|
title: task.title,
|
||||||
`INSERT INTO tasks (title, description, project_id, priority, due_date, status, completed, parent_task_id, recurrence_rule, recurrence_interval, sort_order, created_at, updated_at)
|
description: task.description,
|
||||||
VALUES ($1, $2, $3, $4, $5, 'todo', FALSE, $6, $7, $8, $9, $10, $11)`,
|
projectId: task.projectId || undefined,
|
||||||
[
|
priority: task.priority,
|
||||||
task.title,
|
dueDate: nextDate,
|
||||||
task.description,
|
status: 'todo',
|
||||||
task.project_id,
|
completed: false,
|
||||||
task.priority,
|
parentTaskId: task.id,
|
||||||
nextDateStr,
|
recurrenceRule: task.recurrenceRule,
|
||||||
task.id,
|
recurrenceInterval: task.recurrenceInterval,
|
||||||
task.recurrence_rule,
|
sortOrder: (task.sortOrder || 0) + 1,
|
||||||
task.recurrence_interval,
|
},
|
||||||
(task.sort_order || 0) + 1,
|
});
|
||||||
new Date(),
|
|
||||||
new Date(),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await pool.query(
|
const updated = await prisma.task.update({
|
||||||
`UPDATE tasks SET completed = NOT completed, status = CASE WHEN NOT completed THEN 'done' ELSE status END, updated_at = $1 WHERE id = $2`,
|
where: { id },
|
||||||
[new Date(), id]
|
data: {
|
||||||
);
|
completed: { set: !task.completed },
|
||||||
|
status: task.completed ? 'todo' : 'done',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const updatedResult = await pool.query('SELECT * FROM tasks WHERE id = $1', [id]);
|
return NextResponse.json({
|
||||||
return NextResponse.json(serializeRow(updatedResult.rows[0]));
|
...updated,
|
||||||
|
dueDate: updated.dueDate ? updated.dueDate.toISOString().split('T')[0] : null,
|
||||||
|
nextOccurrence: updated.nextOccurrence ? updated.nextOccurrence.toISOString().split('T')[0] : null,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ error: 'Unknown action' }, { status: 400 });
|
return NextResponse.json({ error: 'Unknown action' }, { status: 400 });
|
||||||
@@ -211,4 +173,4 @@ function getNextOccurrence(fromDate: Date, rule: string, interval: number): Date
|
|||||||
default:
|
default:
|
||||||
return fromDate;
|
return fromDate;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +1,9 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { Pool } from 'pg';
|
import prisma from '@/lib/db';
|
||||||
|
|
||||||
const pool = new Pool({
|
|
||||||
connectionString: process.env.DATABASE_URL || 'postgresql://vixtix:vixtix_secret@localhost:5433/vixtix',
|
|
||||||
});
|
|
||||||
|
|
||||||
function toCamel(str: string): string {
|
|
||||||
return str.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
function serializeRow(row: Record<string, any>): Record<string, any> {
|
|
||||||
const result: Record<string, any> = {};
|
|
||||||
for (const [key, value] of Object.entries(row)) {
|
|
||||||
const camelKey = toCamel(key);
|
|
||||||
if (value instanceof Date && !isNaN(value.getTime())) {
|
|
||||||
// pg driver converts both DATE and TIMESTAMPTZ to Date objects
|
|
||||||
// DATE columns are at midnight UTC (00:00:00.000)
|
|
||||||
// TIMESTAMPTZ columns have actual time components
|
|
||||||
if (value.getUTCHours() === 0 && value.getUTCMinutes() === 0 && value.getUTCSeconds() === 0 && value.getUTCMilliseconds() === 0) {
|
|
||||||
// DATE column - return "YYYY-MM-DD"
|
|
||||||
const year = value.getUTCFullYear();
|
|
||||||
const month = String(value.getUTCMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(value.getUTCDate()).padStart(2, '0');
|
|
||||||
result[camelKey] = `${year}-${month}-${day}`;
|
|
||||||
} else {
|
|
||||||
// TIMESTAMPTZ column - return ISO string
|
|
||||||
result[camelKey] = value.toISOString();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
result[camelKey] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
const project = searchParams.get('project');
|
const project = searchParams.get('project');
|
||||||
const search = searchParams.get('search');
|
const search = searchParams.get('search');
|
||||||
const status = searchParams.get('status');
|
const status = searchParams.get('status');
|
||||||
@@ -47,56 +14,30 @@ export async function GET(request: NextRequest) {
|
|||||||
const sortBy = searchParams.get('sort_by') || 'due_date';
|
const sortBy = searchParams.get('sort_by') || 'due_date';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Build dynamic query
|
const where: Record<string, unknown> = {};
|
||||||
let query = 'SELECT * FROM tasks WHERE 1=1';
|
|
||||||
const params: any[] = [];
|
|
||||||
let paramIndex = 1;
|
|
||||||
|
|
||||||
if (project) {
|
if (project) where.projectId = project;
|
||||||
query += ` AND project_id = $${paramIndex}`;
|
if (search) where.title = { contains: search, mode: 'insensitive' as const };
|
||||||
params.push(project);
|
if (status) where.status = status;
|
||||||
paramIndex++;
|
if (priority) where.priority = priority;
|
||||||
}
|
if (dueBefore) where.dueDate = { lte: new Date(dueBefore) };
|
||||||
if (search) {
|
if (dueAfter) where.dueDate = { gte: new Date(dueAfter) };
|
||||||
query += ` AND title ILIKE $${paramIndex}`;
|
if (completed !== null) where.completed = completed === 'true';
|
||||||
params.push(`%${search}%`);
|
|
||||||
paramIndex++;
|
|
||||||
}
|
|
||||||
if (status) {
|
|
||||||
query += ` AND status = $${paramIndex}`;
|
|
||||||
params.push(status);
|
|
||||||
paramIndex++;
|
|
||||||
}
|
|
||||||
if (priority) {
|
|
||||||
query += ` AND priority = $${paramIndex}`;
|
|
||||||
params.push(priority);
|
|
||||||
paramIndex++;
|
|
||||||
}
|
|
||||||
if (dueBefore) {
|
|
||||||
query += ` AND due_date <= $${paramIndex}`;
|
|
||||||
params.push(dueBefore);
|
|
||||||
paramIndex++;
|
|
||||||
}
|
|
||||||
if (dueAfter) {
|
|
||||||
query += ` AND due_date >= $${paramIndex}`;
|
|
||||||
params.push(dueAfter);
|
|
||||||
paramIndex++;
|
|
||||||
}
|
|
||||||
if (completed !== null) {
|
|
||||||
query += ` AND completed = $${paramIndex}`;
|
|
||||||
params.push(completed === 'true');
|
|
||||||
paramIndex++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort order
|
// Sort order - Prisma 6 requires orderBy as an array of sort objects
|
||||||
const sortColumn = sortBy === 'priority' ? 'priority' : 'due_date';
|
const orderBy: Array<Record<string, unknown>> = sortBy === 'priority'
|
||||||
const sortOrder = sortBy === 'priority' ? 'CASE priority WHEN \'urgent\' THEN 1 WHEN \'high\' THEN 2 WHEN \'medium\' THEN 3 WHEN \'low\' THEN 4 END' : 'due_date';
|
? [{ priority: 'asc' }, { title: 'asc' }]
|
||||||
query += ` ORDER BY ${sortColumn === 'due_date' ? 'due_date NULLS LAST' : sortOrder}`;
|
: [{ dueDate: { sort: 'asc', nulls: 'last' } }, { title: 'asc' }];
|
||||||
query += ', title';
|
|
||||||
|
|
||||||
const result = await pool.query(query, params);
|
const tasks = await prisma.task.findMany({ where, orderBy });
|
||||||
|
|
||||||
return NextResponse.json(result.rows.map(serializeRow));
|
return NextResponse.json(
|
||||||
|
tasks.map((t) => ({
|
||||||
|
...t,
|
||||||
|
dueDate: t.dueDate ? t.dueDate.toISOString().split('T')[0] : null,
|
||||||
|
nextOccurrence: t.nextOccurrence ? t.nextOccurrence.toISOString().split('T')[0] : null,
|
||||||
|
}))
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching tasks:', error);
|
console.error('Error fetching tasks:', error);
|
||||||
return NextResponse.json({ error: 'Failed to fetch tasks' }, { status: 500 });
|
return NextResponse.json({ error: 'Failed to fetch tasks' }, { status: 500 });
|
||||||
@@ -105,7 +46,6 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
let body;
|
let body;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
body = await request.json();
|
body = await request.json();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -119,26 +59,34 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await pool.query(
|
// Get max sort_order for the project (or overall if no project)
|
||||||
`INSERT INTO tasks (title, description, project_id, priority, due_date, status, recurrence_rule, recurrence_interval, parent_task_id, sort_order)
|
const maxSort = await prisma.task.aggregate({
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, (SELECT COALESCE(MAX(sort_order), 0) + 1 FROM tasks))
|
where: projectId ? { projectId } : undefined,
|
||||||
RETURNING *`,
|
_max: { sortOrder: true },
|
||||||
[
|
});
|
||||||
title.trim(),
|
|
||||||
description?.trim() || '',
|
|
||||||
projectId || null,
|
|
||||||
priority || 'medium',
|
|
||||||
dueDate || null,
|
|
||||||
status || 'todo',
|
|
||||||
recurrenceRule || 'none',
|
|
||||||
recurrenceInterval || 1,
|
|
||||||
parentTaskId || null,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json(serializeRow(result.rows[0]));
|
const task = await prisma.task.create({
|
||||||
|
data: {
|
||||||
|
title: title.trim(),
|
||||||
|
description: description?.trim() || '',
|
||||||
|
projectId: projectId || undefined,
|
||||||
|
priority: priority || 'medium',
|
||||||
|
dueDate: dueDate ? new Date(dueDate) : undefined,
|
||||||
|
status: status || 'todo',
|
||||||
|
recurrenceRule: recurrenceRule || 'none',
|
||||||
|
recurrenceInterval: recurrenceInterval || 1,
|
||||||
|
parentTaskId: parentTaskId || undefined,
|
||||||
|
sortOrder: (maxSort._max.sortOrder ?? 0) + 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
...task,
|
||||||
|
dueDate: task.dueDate ? task.dueDate.toISOString().split('T')[0] : null,
|
||||||
|
nextOccurrence: task.nextOccurrence ? task.nextOccurrence.toISOString().split('T')[0] : null,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating task:', error);
|
console.error('Error creating task:', error);
|
||||||
return NextResponse.json({ error: 'Failed to create task' }, { status: 500 });
|
return NextResponse.json({ error: 'Failed to create task' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
13
src/lib/db.ts
Normal file
13
src/lib/db.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { PrismaClient } from '@/generated/prisma/client';
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
|
||||||
|
|
||||||
|
export const prisma =
|
||||||
|
globalForPrisma.prisma ||
|
||||||
|
new PrismaClient({
|
||||||
|
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
|
||||||
|
|
||||||
|
export default prisma;
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
import { Pool } from 'pg';
|
|
||||||
|
|
||||||
const connectionString = process.env.DATABASE_URL || 'postgresql://vixtix:vixtix_secret@localhost:5433/vixtix';
|
|
||||||
const pool = new Pool({ connectionString });
|
|
||||||
|
|
||||||
async function migrate() {
|
|
||||||
console.log('Running migrations...');
|
|
||||||
|
|
||||||
const client = await pool.connect();
|
|
||||||
try {
|
|
||||||
await client.query('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"');
|
|
||||||
|
|
||||||
// Create enums - use DO block to handle duplicates
|
|
||||||
await client.query(`
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'priority') THEN
|
|
||||||
CREATE TYPE priority AS ENUM ('low', 'medium', 'high', 'urgent');
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
`);
|
|
||||||
await client.query(`
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'status') THEN
|
|
||||||
CREATE TYPE status AS ENUM ('todo', 'in_progress', 'done');
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
`);
|
|
||||||
await client.query(`
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'recurrence') THEN
|
|
||||||
CREATE TYPE recurrence AS ENUM ('daily', 'weekly', 'biweekly', 'monthly', 'yearly', 'none');
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
`);
|
|
||||||
|
|
||||||
await client.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS projects (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
name TEXT NOT NULL, description TEXT DEFAULT '', color TEXT DEFAULT '#3b82f6',
|
|
||||||
sort_order INTEGER DEFAULT 0,
|
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
|
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
|
|
||||||
await client.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS tasks (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
project_id UUID REFERENCES projects(id),
|
|
||||||
title TEXT NOT NULL, description TEXT DEFAULT '',
|
|
||||||
completed BOOLEAN DEFAULT FALSE NOT NULL,
|
|
||||||
priority priority DEFAULT 'medium' NOT NULL,
|
|
||||||
due_date DATE,
|
|
||||||
status status DEFAULT 'todo' NOT NULL,
|
|
||||||
parent_task_id UUID REFERENCES tasks(id),
|
|
||||||
recurrence_rule recurrence DEFAULT 'none' NOT NULL,
|
|
||||||
recurrence_interval INTEGER DEFAULT 1 NOT NULL,
|
|
||||||
next_occurrence DATE,
|
|
||||||
sort_order INTEGER DEFAULT 0,
|
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
|
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
|
|
||||||
await client.query(`
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tasks_project_id ON tasks(project_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tasks_parent_task_id ON tasks(parent_task_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tasks_completed ON tasks(completed);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tasks_due_date ON tasks(due_date);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tasks_recurrence ON tasks(recurrence_rule);
|
|
||||||
`);
|
|
||||||
|
|
||||||
const count = await client.query('SELECT COUNT(*) FROM projects');
|
|
||||||
if (parseInt(count.rows[0].count) === 0) {
|
|
||||||
console.log('Seeding initial data...');
|
|
||||||
await client.query(`
|
|
||||||
INSERT INTO projects (name, description, color, sort_order) VALUES
|
|
||||||
('Personal', 'Personal tasks and goals', '#3b82f6', 1),
|
|
||||||
('Work', 'Work-related tasks', '#10b981', 2),
|
|
||||||
('Health', 'Health and fitness', '#f59e0b', 3),
|
|
||||||
('Finance', 'Financial tasks and tracking', '#8b5cf6', 4);
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Use DATE arithmetic instead of INTERVAL
|
|
||||||
await client.query(`
|
|
||||||
INSERT INTO tasks (project_id, title, description, priority, status, due_date, sort_order)
|
|
||||||
SELECT p.id, t.title, t.description, t.priority::priority, t.status::status, t.due_date, t.sort_order
|
|
||||||
FROM (
|
|
||||||
VALUES
|
|
||||||
('Personal', 'Set up daily routine', 'Morning meditation, exercise, and planning', 'high', 'todo', CURRENT_DATE + 1, 1),
|
|
||||||
('Personal', 'Read 30 minutes', 'Read a book or articles', 'medium', 'todo', CURRENT_DATE + 2, 2),
|
|
||||||
('Personal', 'Clean apartment', 'Deep clean kitchen and bathrooms', 'medium', 'in_progress', CURRENT_DATE + 3, 3),
|
|
||||||
('Work', 'Review sprint backlog', 'Prioritize tasks for next sprint', 'high', 'todo', CURRENT_DATE + 1, 1),
|
|
||||||
('Work', 'Update documentation', 'Add API docs for new endpoints', 'medium', 'in_progress', CURRENT_DATE + 5, 2),
|
|
||||||
('Work', 'Code review', 'Review pull requests from team', 'low', 'done', CURRENT_DATE - 1, 3),
|
|
||||||
('Health', 'Gym workout', 'Upper body strength training', 'high', 'todo', CURRENT_DATE + 1, 1),
|
|
||||||
('Health', 'Meal prep', 'Prepare healthy meals for the week', 'medium', 'todo', CURRENT_DATE + 2, 2),
|
|
||||||
('Health', 'Track water intake', 'Drink at least 8 glasses of water', 'low', 'done', CURRENT_DATE - 1, 3),
|
|
||||||
('Finance', 'Review monthly budget', 'Check spending and adjust categories', 'high', 'todo', CURRENT_DATE + 3, 1),
|
|
||||||
('Finance', 'Pay bills', 'Electricity, internet, phone', 'urgent', 'todo', CURRENT_DATE + 1, 2),
|
|
||||||
('Finance', 'Investment review', 'Check portfolio performance', 'medium', 'todo', CURRENT_DATE + 7, 3)
|
|
||||||
) AS t(name, title, description, priority, status, due_date, sort_order)
|
|
||||||
JOIN projects p ON p.name = t.name;
|
|
||||||
`);
|
|
||||||
console.log('Seed data inserted successfully!');
|
|
||||||
} else {
|
|
||||||
console.log('Database already seeded.');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Migrations completed successfully!');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Migration failed:', error);
|
|
||||||
process.exit(1);
|
|
||||||
} finally {
|
|
||||||
client.release();
|
|
||||||
await pool.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
migrate();
|
|
||||||
63
src/server/db/seed.ts
Normal file
63
src/server/db/seed.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient({
|
||||||
|
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
||||||
|
});
|
||||||
|
|
||||||
|
async function seed() {
|
||||||
|
console.log('Seeding database...');
|
||||||
|
|
||||||
|
const count = await prisma.project.count();
|
||||||
|
if (count > 0) {
|
||||||
|
console.log('Database already seeded.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const projects = await prisma.project.createManyAndReturn({
|
||||||
|
data: [
|
||||||
|
{ name: 'Personal', description: 'Personal tasks and goals', color: '#3b82f6', sortOrder: 1 },
|
||||||
|
{ name: 'Work', description: 'Work-related tasks', color: '#10b981', sortOrder: 2 },
|
||||||
|
{ name: 'Health', description: 'Health and fitness', color: '#f59e0b', sortOrder: 3 },
|
||||||
|
{ name: 'Finance', description: 'Financial tasks and tracking', color: '#8b5cf6', sortOrder: 4 },
|
||||||
|
],
|
||||||
|
}) as Array<{ id: string; name: string }>;
|
||||||
|
|
||||||
|
// Map project names to IDs
|
||||||
|
const projectMap = new Map(projects.map(p => [p.name, p.id]));
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
const tasks: Array<{
|
||||||
|
projectId: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
priority: 'low' | 'medium' | 'high' | 'urgent';
|
||||||
|
status: 'todo' | 'in_progress' | 'done';
|
||||||
|
dueDate: Date;
|
||||||
|
sortOrder: number;
|
||||||
|
}> = [
|
||||||
|
{ projectId: projectMap.get('Personal')!, title: 'Set up daily routine', description: 'Morning meditation, exercise, and planning', priority: 'high', status: 'todo', dueDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1), sortOrder: 1 },
|
||||||
|
{ projectId: projectMap.get('Personal')!, title: 'Read 30 minutes', description: 'Read a book or articles', priority: 'medium', status: 'todo', dueDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 2), sortOrder: 2 },
|
||||||
|
{ projectId: projectMap.get('Personal')!, title: 'Clean apartment', description: 'Deep clean kitchen and bathrooms', priority: 'medium', status: 'in_progress', dueDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 3), sortOrder: 3 },
|
||||||
|
{ projectId: projectMap.get('Work')!, title: 'Review sprint backlog', description: 'Prioritize tasks for next sprint', priority: 'high', status: 'todo', dueDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1), sortOrder: 1 },
|
||||||
|
{ projectId: projectMap.get('Work')!, title: 'Update documentation', description: 'Add API docs for new endpoints', priority: 'medium', status: 'in_progress', dueDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 5), sortOrder: 2 },
|
||||||
|
{ projectId: projectMap.get('Work')!, title: 'Code review', description: 'Review pull requests from team', priority: 'low', status: 'done', dueDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1), sortOrder: 3 },
|
||||||
|
{ projectId: projectMap.get('Health')!, title: 'Gym workout', description: 'Upper body strength training', priority: 'high', status: 'todo', dueDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1), sortOrder: 1 },
|
||||||
|
{ projectId: projectMap.get('Health')!, title: 'Meal prep', description: 'Prepare healthy meals for the week', priority: 'medium', status: 'todo', dueDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 2), sortOrder: 2 },
|
||||||
|
{ projectId: projectMap.get('Health')!, title: 'Track water intake', description: 'Drink at least 8 glasses of water', priority: 'low', status: 'done', dueDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1), sortOrder: 3 },
|
||||||
|
{ projectId: projectMap.get('Finance')!, title: 'Review monthly budget', description: 'Check spending and adjust categories', priority: 'high', status: 'todo', dueDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 3), sortOrder: 1 },
|
||||||
|
{ projectId: projectMap.get('Finance')!, title: 'Pay bills', description: 'Electricity, internet, phone', priority: 'urgent', status: 'todo', dueDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1), sortOrder: 2 },
|
||||||
|
{ projectId: projectMap.get('Finance')!, title: 'Investment review', description: 'Check portfolio performance', priority: 'medium', status: 'todo', dueDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 7), sortOrder: 3 },
|
||||||
|
];
|
||||||
|
|
||||||
|
await prisma.task.createMany({ data: tasks });
|
||||||
|
console.log('Seed data inserted successfully!');
|
||||||
|
}
|
||||||
|
|
||||||
|
seed()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('Seed failed:', e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import '@testing-library/jest-dom/vitest';
|
|
||||||
import { cleanup } from '@testing-library/react';
|
|
||||||
import { afterEach } from 'vitest';
|
|
||||||
|
|
||||||
// Auto cleanup after each test
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
13
start.sh
13
start.sh
@@ -10,8 +10,15 @@ for i in $(seq 1 60); do
|
|||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "Running migrations..."
|
echo "Running Prisma migrations..."
|
||||||
tsx ./migrate.ts
|
# Check if migrations have already been applied
|
||||||
|
if [ -d "prisma/migrations" ] && [ "$(ls -A prisma/migrations 2>/dev/null)" ]; then
|
||||||
|
echo "Applying existing migrations..."
|
||||||
|
npx prisma migrate deploy
|
||||||
|
else
|
||||||
|
echo "Pushing schema to database (first run)..."
|
||||||
|
npx prisma db push --accept-data-loss
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Starting VixTix..."
|
echo "Starting VixTix..."
|
||||||
exec node server.js
|
exec npx next start -p 3000
|
||||||
|
|||||||
@@ -19,7 +19,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"],
|
||||||
|
"@/generated/*": ["./src/generated/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
@@ -30,5 +31,5 @@
|
|||||||
".next/dev/types/**/*.ts",
|
".next/dev/types/**/*.ts",
|
||||||
"**/*.mts"
|
"**/*.mts"
|
||||||
],
|
],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules", "vitest.config.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user