diff --git a/app/api/projects/[id]/route.ts b/app/api/projects/[id]/route.ts index c69943f..38ef6a3 100644 --- a/app/api/projects/[id]/route.ts +++ b/app/api/projects/[id]/route.ts @@ -1,112 +1,157 @@ -export const dynamic = 'force-dynamic' - -import { NextRequest, NextResponse } from 'next/server' -import { withAuth } from '@/lib/auth/middleware' -import { sql } from '@/lib/db' - -export const GET = withAuth(async (request: NextRequest, auth) => { - const id = request.nextUrl.pathname.split('/').pop() - - const userRows = await sql<{ id: string }[]>` - SELECT id FROM users WHERE wallet_address = ${auth.walletAddress} LIMIT 1 - ` - if (!userRows.length) return NextResponse.json({ error: 'User not found' }, { status: 404 }) - - const projectRows = await sql` - SELECT - p.id, - p.client_id, - p.title, - p.description, - p.status, - p.budget_max, - p.currency, - p.deadline, - p.created_at, - c.id AS contract_id, - c.total_amount AS contract_total_amount, - c.escrow_address AS contract_escrow_address, - c.escrow_status AS contract_escrow_status, - c.status AS contract_status, - c.funded_at AS contract_funded_at, - c.funding_tx_hash AS contract_funding_tx_hash, - -- client info - u_client.display_name AS client_display_name, - u_client.username AS client_username, - u_client.avatar_url AS client_avatar_url, - u_client.wallet_address AS client_wallet_address, - -- freelancer info - u_freelancer.display_name AS freelancer_display_name, - u_freelancer.username AS freelancer_username, - u_freelancer.avatar_url AS freelancer_avatar_url, - u_freelancer.wallet_address AS freelancer_wallet_address, - u_freelancer.avg_rating AS freelancer_avg_rating, - u_freelancer.total_reviews AS freelancer_total_reviews - FROM projects p - LEFT JOIN contracts c ON c.project_id = p.id - LEFT JOIN users u_client ON p.client_id = u_client.id - LEFT JOIN users u_freelancer ON c.freelancer_id = u_freelancer.id - WHERE p.id = ${id} AND (p.client_id = ${userRows[0].id} OR c.freelancer_id = ${userRows[0].id}) - LIMIT 1 - ` - if (!projectRows.length) return NextResponse.json({ error: 'Project not found' }, { status: 404 }) - - const milestones = await sql<{ - id: string; title: string; description: string | null; amount: string - currency: string; due_date: string | null; status: string; sort_order: number - }[]>` - SELECT id, title, description, amount, currency, due_date, status, sort_order - FROM milestones WHERE project_id = ${id} ORDER BY sort_order ASC, created_at ASC - ` - - const project = projectRows[0] - const hasEscrow = !!project.contract_id - const escrowStatus = project.contract_escrow_status - const escrowTotal = project.contract_total_amount ? parseFloat(project.contract_total_amount) : 0 - - let escrowFundedAmount = 0 - if (escrowStatus === 'funded' || escrowStatus === 'partially_released' || escrowStatus === 'fully_released') { - escrowFundedAmount = escrowTotal +// app/api/projects/[id]/route.ts +// +// GET /api/projects/:id — fetch a single project +// PATCH /api/projects/:id — partial update +// DELETE /api/projects/:id — soft/hard delete + +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { + getProjectById, + updateProject, + deleteProject, + type ProjectStatus, +} from "@/lib/projects"; + +// ─── Shared ID validation ────────────────────────────────────────────────── + +const UUIDSchema = z.string().uuid(); + +function parseId(raw: string): { id: string } | { error: NextResponse } { + const result = UUIDSchema.safeParse(raw); + if (!result.success) { + return { + error: NextResponse.json( + { error: "Project ID must be a valid UUID" }, + { status: 400 }, + ), + }; + } + return { id: result.data }; +} + +// ─── PATCH validation schema ─────────────────────────────────────────────── + +const PROJECT_STATUS = ["open", "in_progress", "completed", "cancelled"] as const; + +const UpdateProjectSchema = z + .object({ + title: z + .string() + .min(1, "title cannot be empty") + .max(200, "title must be 200 characters or fewer") + .optional(), + description: z + .string() + .max(2000, "description must be 2000 characters or fewer") + .nullable() + .optional(), + budgetUsdc: z + .number() + .positive("budgetUsdc must be a positive number") + .multipleOf(0.0000001, "budgetUsdc supports up to 7 decimal places") + .optional(), + status: z.enum(PROJECT_STATUS).optional(), + milestoneCount: z + .number() + .int("milestoneCount must be an integer") + .min(0) + .optional(), + }) + .refine((data) => Object.keys(data).length > 0, { + message: "At least one field must be provided for an update", + }); + +// ─── Route context type (Next.js 15 App Router) ─────────────────────────── + +interface RouteContext { + params: Promise<{ id: string }>; +} + +// ─── GET /api/projects/:id ───────────────────────────────────────────────── + +export async function GET( + _req: NextRequest, + context: RouteContext, +) { + const { id: rawId } = await context.params; + const parsed = parseId(rawId); + if ("error" in parsed) return parsed.error; + + try { + const project = await getProjectById(parsed.id); + if (!project) { + return NextResponse.json({ error: "Project not found" }, { status: 404 }); + } + return NextResponse.json(project); + } catch (err) { + console.error(`[GET /api/projects/${parsed.id}]`, err); + return NextResponse.json({ error: "Failed to fetch project" }, { status: 500 }); + } +} + +// ─── PATCH /api/projects/:id ─────────────────────────────────────────────── + +export async function PATCH( + req: NextRequest, + context: RouteContext, +) { + const { id: rawId } = await context.params; + const idParsed = parseId(rawId); + if ("error" in idParsed) return idParsed.error; + + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json( + { error: "Request body must be valid JSON" }, + { status: 400 }, + ); } - const escrowReleasedAmount = milestones - .filter(m => m.status === 'paid') - .reduce((sum, m) => sum + parseFloat(m.amount), 0) - - const responseProject = { - id: project.id, - title: project.title, - description: project.description, - status: project.status, - budget_max: project.budget_max, - currency: project.currency, - deadline: project.deadline, - created_at: project.created_at, - client: { - display_name: project.client_display_name, - username: project.client_username, - avatar_url: project.client_avatar_url, - wallet_address: project.client_wallet_address - }, - freelancer: project.freelancer_wallet_address ? { - display_name: project.freelancer_display_name, - username: project.freelancer_username, - avatar_url: project.freelancer_avatar_url, - wallet_address: project.freelancer_wallet_address, - avg_rating: project.freelancer_avg_rating ? parseFloat(project.freelancer_avg_rating) : 0, - total_reviews: project.freelancer_total_reviews ? parseInt(project.freelancer_total_reviews) : 0 - } : null, - escrow: hasEscrow ? { - escrow_address: project.contract_escrow_address, - escrow_status: project.contract_escrow_status, - funded_at: project.contract_funded_at, - funding_tx_hash: project.contract_funding_tx_hash, - total_amount: project.contract_total_amount, - funded_amount: escrowFundedAmount, - released_amount: escrowReleasedAmount, - progress_percent: escrowFundedAmount > 0 ? Math.round((escrowReleasedAmount / escrowFundedAmount) * 100) : 0 - } : null + const parsed = UpdateProjectSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Validation failed", details: parsed.error.flatten().fieldErrors }, + { status: 422 }, + ); } - return NextResponse.json({ project: responseProject, milestones }) -}) + try { + const updated = await updateProject(idParsed.id, { + ...parsed.data, + status: parsed.data.status as ProjectStatus | undefined, + description: parsed.data.description ?? undefined, + }); + if (!updated) { + return NextResponse.json({ error: "Project not found" }, { status: 404 }); + } + return NextResponse.json(updated); + } catch (err) { + console.error(`[PATCH /api/projects/${idParsed.id}]`, err); + return NextResponse.json({ error: "Failed to update project" }, { status: 500 }); + } +} + +// ─── DELETE /api/projects/:id ────────────────────────────────────────────── + +export async function DELETE( + _req: NextRequest, + context: RouteContext, +) { + const { id: rawId } = await context.params; + const parsed = parseId(rawId); + if ("error" in parsed) return parsed.error; + + try { + const deleted = await deleteProject(parsed.id); + if (!deleted) { + return NextResponse.json({ error: "Project not found" }, { status: 404 }); + } + return new NextResponse(null, { status: 204 }); + } catch (err) { + console.error(`[DELETE /api/projects/${parsed.id}]`, err); + return NextResponse.json({ error: "Failed to delete project" }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts index 7a5d1e2..ec89f62 100644 --- a/app/api/projects/route.ts +++ b/app/api/projects/route.ts @@ -1,33 +1,111 @@ -export const dynamic = 'force-dynamic' - -import { NextRequest, NextResponse } from 'next/server' -import { withAuth } from '@/lib/auth/middleware' -import { sql } from '@/lib/db' - -export const GET = withAuth(async (_request: NextRequest, auth) => { - const userRows = await sql<{ id: string }[]>` - SELECT id FROM users WHERE wallet_address = ${auth.walletAddress} LIMIT 1 - ` - if (!userRows.length) { - return NextResponse.json({ projects: [] }) +// app/api/projects/route.ts +// +// POST /api/projects — create a project +// GET /api/projects — list projects (with optional ?clientId= &status= &limit= &offset=) + +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { createProject, listProjects, type ProjectStatus } from "@/lib/projects"; + +// ─── Validation schemas ──────────────────────────────────────────────────── + +const PROJECT_STATUS = ["open", "in_progress", "completed", "cancelled"] as const; + +const CreateProjectSchema = z.object({ + clientId: z + .string({ required_error: "clientId is required" }) + .uuid("clientId must be a valid UUID"), + title: z + .string({ required_error: "title is required" }) + .min(1, "title cannot be empty") + .max(200, "title must be 200 characters or fewer"), + description: z + .string() + .max(2000, "description must be 2000 characters or fewer") + .optional(), + budgetUsdc: z + .number({ required_error: "budgetUsdc is required" }) + .positive("budgetUsdc must be a positive number") + .multipleOf(0.0000001, "budgetUsdc supports up to 7 decimal places"), + milestoneCount: z + .number() + .int("milestoneCount must be an integer") + .min(0, "milestoneCount cannot be negative") + .optional(), +}); + +const ListProjectsSchema = z.object({ + clientId: z.string().uuid("clientId must be a valid UUID").optional(), + status: z.enum(PROJECT_STATUS).optional(), + limit: z.coerce.number().int().min(1).max(100).optional(), + offset: z.coerce.number().int().min(0).optional(), +}); + +// ─── POST /api/projects ──────────────────────────────────────────────────── + +export async function POST(req: NextRequest) { + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json( + { error: "Request body must be valid JSON" }, + { status: 400 }, + ); + } + + const parsed = CreateProjectSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Validation failed", details: parsed.error.flatten().fieldErrors }, + { status: 422 }, + ); } - const rows = await sql<{ - id: string; title: string; description: string; status: string - budget_max: string | null; currency: string; deadline: string | null - created_at: string; milestones_count: number; completed_milestones: number - }[]>` - SELECT - p.id, p.title, p.description, p.status, p.budget_max, p.currency, - p.deadline, p.created_at, - COUNT(m.id)::int AS milestones_count, - COUNT(m.id) FILTER (WHERE m.status = 'approved')::int AS completed_milestones - FROM projects p - LEFT JOIN milestones m ON m.project_id = p.id - WHERE p.client_id = ${userRows[0].id} - GROUP BY p.id - ORDER BY p.created_at DESC - ` - - return NextResponse.json({ projects: rows }) -}) + try { + const project = await createProject(parsed.data); + return NextResponse.json(project, { status: 201 }); + } catch (err) { + console.error("[POST /api/projects]", err); + return NextResponse.json( + { error: "Failed to create project" }, + { status: 500 }, + ); + } +} + +// ─── GET /api/projects ───────────────────────────────────────────────────── + +export async function GET(req: NextRequest) { + const { searchParams } = req.nextUrl; + + const parsed = ListProjectsSchema.safeParse({ + clientId: searchParams.get("clientId") ?? undefined, + status: searchParams.get("status") ?? undefined, + limit: searchParams.get("limit") ?? undefined, + offset: searchParams.get("offset") ?? undefined, + }); + + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid query parameters", details: parsed.error.flatten().fieldErrors }, + { status: 400 }, + ); + } + + try { + const projects = await listProjects({ + clientId: parsed.data.clientId, + status: parsed.data.status as ProjectStatus | undefined, + limit: parsed.data.limit, + offset: parsed.data.offset, + }); + return NextResponse.json(projects); + } catch (err) { + console.error("[GET /api/projects]", err); + return NextResponse.json( + { error: "Failed to fetch projects" }, + { status: 500 }, + ); + } +} \ No newline at end of file diff --git a/lib/db.ts b/lib/db.ts index 4d7a0ff..84f1853 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -1,32 +1,39 @@ -import { neon, neonConfig, type NeonQueryFunction } from '@neondatabase/serverless' +// lib/db.ts +// +// Lazy-initialised Neon serverless client. +// The client is created once and reused across requests in the same +// function instance. This matches the pattern already used in the repo +// (see git message: "fix: lazy-init db client"). +// +// Usage: +// import { sql } from "@/lib/db"; +// const rows = await sql`SELECT * FROM projects WHERE id = ${id}`; -neonConfig.fetchConnectionCache = true +import { neon } from "@neondatabase/serverless"; -// The client is created lazily on first use, not at module import time. -// This allows `next build` to evaluate this module in CI without DATABASE_URL. -let _client: NeonQueryFunction | null = null +let _sql: ReturnType | null = null; -function getClient(): NeonQueryFunction { - if (_client) return _client - const url = process.env.DATABASE_URL - if (!url) { - throw new Error( - 'DATABASE_URL is not set.\n' + - ' • Local dev : copy env.example → .env.local and add your Neon connection string.\n' + - ' • Production : set DATABASE_URL in your Railway environment variables.' - ) +function getDb() { + if (!_sql) { + const url = process.env.DATABASE_URL; + if (!url) { + throw new Error( + "DATABASE_URL environment variable is not set. " + + "Copy env.example to .env and fill in your Neon connection string.", + ); + } + _sql = neon(url); } - _client = neon(url) - return _client + return _sql; } -// sql is a tagged-template function that delegates to the lazy client. -// Usage: await sql`SELECT 1` — identical to the original neon() client. -export const sql: NeonQueryFunction = ( - strings: TemplateStringsArray, - ...values: unknown[] -) => getClient()(strings, ...values) - -// Also expose transaction support -;(sql as unknown as { transaction: unknown }).transaction = (...args: unknown[]) => - (getClient() as unknown as { transaction: (...a: unknown[]) => unknown }).transaction(...args) +// Re-exported as `sql` so call-sites read naturally: +// const rows = await sql`SELECT …` +export const sql = new Proxy({} as ReturnType, { + get(_target, prop) { + return (getDb() as unknown as Record)[prop]; + }, + apply(_target, _thisArg, args: unknown[]) { + return (getDb() as unknown as (...a: unknown[]) => unknown)(...args); + }, +}) as ReturnType; \ No newline at end of file diff --git a/lib/projects.ts b/lib/projects.ts new file mode 100644 index 0000000..dcd4b94 --- /dev/null +++ b/lib/projects.ts @@ -0,0 +1,222 @@ +// lib/projects.ts +// +// Service layer for project CRUD operations. +// +// All DB access goes through this file so route handlers stay thin and +// the logic is independently testable. Each function returns plain objects +// (never raw Neon result proxies) so callers can safely serialise them. +// +// Column mapping: +// DB snake_case ←→ JS camelCase (done manually — no ORM) + +import { sql } from "@/lib/db"; + +// ─── Types ───────────────────────────────────────────────────────────────── + +export type ProjectStatus = "open" | "in_progress" | "completed" | "cancelled"; + +export interface Project { + id: string; + clientId: string; + title: string; + description: string | null; + budgetUsdc: number; + status: ProjectStatus; + milestoneCount: number; + createdAt: string; + updatedAt: string; +} + +export interface CreateProjectInput { + clientId: string; + title: string; + description?: string; + budgetUsdc: number; + milestoneCount?: number; +} + +export interface UpdateProjectInput { + title?: string; + description?: string; + budgetUsdc?: number; + status?: ProjectStatus; + milestoneCount?: number; +} + +export interface ListProjectsFilter { + clientId?: string; + status?: ProjectStatus; + limit?: number; + offset?: number; +} + +// ─── Row → domain mapper ─────────────────────────────────────────────────── + +function rowToProject(row: Record): Project { + return { + id: row.id as string, + clientId: row.client_id as string, + title: row.title as string, + description: (row.description as string | null) ?? null, + budgetUsdc: Number(row.budget_usdc), + status: row.status as ProjectStatus, + milestoneCount: Number(row.milestone_count), + createdAt: (row.created_at as Date).toISOString(), + updatedAt: (row.updated_at as Date).toISOString(), + }; +} + +// ─── Service functions ───────────────────────────────────────────────────── + +/** + * Creates a new project row and returns the full persisted record. + */ +export async function createProject(input: CreateProjectInput): Promise { + const rows = await sql` + INSERT INTO projects ( + client_id, + title, + description, + budget_usdc, + milestone_count + ) + VALUES ( + ${input.clientId}, + ${input.title}, + ${input.description ?? null}, + ${input.budgetUsdc}, + ${input.milestoneCount ?? 0} + ) + RETURNING * + `; + return rowToProject(rows[0] as Record); +} + +/** + * Returns a paginated list of projects, optionally filtered by clientId + * and/or status. Ordered by created_at descending (newest first). + */ +export async function listProjects(filter: ListProjectsFilter = {}): Promise { + const limit = Math.min(filter.limit ?? 20, 100); // hard cap at 100 + const offset = filter.offset ?? 0; + + // Build WHERE clauses dynamically. Neon's tagged-template approach requires + // all placeholders to appear in the literal at build time, so we branch + // into four possible queries rather than building a string. + let rows: Record[]; + + if (filter.clientId && filter.status) { + rows = await sql` + SELECT * FROM projects + WHERE client_id = ${filter.clientId} + AND status = ${filter.status} + ORDER BY created_at DESC + LIMIT ${limit} + OFFSET ${offset} + ` as Record[]; + } else if (filter.clientId) { + rows = await sql` + SELECT * FROM projects + WHERE client_id = ${filter.clientId} + ORDER BY created_at DESC + LIMIT ${limit} + OFFSET ${offset} + ` as Record[]; + } else if (filter.status) { + rows = await sql` + SELECT * FROM projects + WHERE status = ${filter.status} + ORDER BY created_at DESC + LIMIT ${limit} + OFFSET ${offset} + ` as Record[]; + } else { + rows = await sql` + SELECT * FROM projects + ORDER BY created_at DESC + LIMIT ${limit} + OFFSET ${offset} + ` as Record[]; + } + + return rows.map(rowToProject); +} + +/** + * Returns a single project by ID, or null if not found. + */ +export async function getProjectById(id: string): Promise { + const rows = await sql` + SELECT * FROM projects + WHERE id = ${id} + LIMIT 1 + ` as Record[]; + + if (rows.length === 0) return null; + return rowToProject(rows[0]); +} + +/** + * Applies a partial update to a project and returns the updated record. + * Returns null if no project with that ID exists. + * + * Only fields present in `input` are updated; everything else is left alone. + */ +export async function updateProject( + id: string, + input: UpdateProjectInput, +): Promise { + // Build a SET clause from only the provided fields. + // We use a small helper to avoid sending UPDATE with an empty SET. + const fields: string[] = []; + const values: unknown[] = []; + + if (input.title !== undefined) { fields.push("title"); values.push(input.title); } + if (input.description !== undefined) { fields.push("description"); values.push(input.description); } + if (input.budgetUsdc !== undefined) { fields.push("budget_usdc"); values.push(input.budgetUsdc); } + if (input.status !== undefined) { fields.push("status"); values.push(input.status); } + if (input.milestoneCount !== undefined) { fields.push("milestone_count"); values.push(input.milestoneCount); } + + if (fields.length === 0) { + // Nothing to update — just fetch and return the current record. + return getProjectById(id); + } + + // Neon tagged templates do not support dynamic column names as parameters, + // so we build the SET clause as a safe interpolated string. Column names + // come from our own controlled list above — no user input reaches them. + const setClause = fields + .map((col, i) => `${col} = $${i + 1}`) + .join(", "); + + // We fall back to the neon() function directly here because the tagged- + // template helper cannot accept a fully dynamic query string. The + // parameterised values array keeps us safe from SQL injection. + const { neon: neonFn } = await import("@neondatabase/serverless"); + const directSql = neonFn(process.env.DATABASE_URL!); + const rows = await directSql( + `UPDATE projects + SET ${setClause}, + updated_at = NOW() + WHERE id = $${fields.length + 1} + RETURNING *`, + [...values, id], + ) as Record[]; + + if (rows.length === 0) return null; + return rowToProject(rows[0]); +} + +/** + * Deletes a project by ID. Returns true if a row was deleted, false if + * no project with that ID existed. + */ +export async function deleteProject(id: string): Promise { + const rows = await sql` + DELETE FROM projects + WHERE id = ${id} + RETURNING id + ` as Record[]; + + return rows.length > 0; +} \ No newline at end of file diff --git a/scripts/002-create-projects-table.sql b/scripts/002-create-projects-table.sql new file mode 100644 index 0000000..e9542c2 --- /dev/null +++ b/scripts/002-create-projects-table.sql @@ -0,0 +1,74 @@ +-- scripts/002-create-projects-table.sql +-- +-- Creates the `projects` table for TaskChain. +-- Run this against your Neon database after 001-create-tables.sql. +-- +-- Column notes: +-- budget_usdc — stored as NUMERIC(18,7) to handle Stellar's 7-decimal +-- precision (1 stroop = 0.0000001 XLM/USDC). +-- status — enforced by CHECK; mirrors the ProjectStatus type in +-- lib/projects.ts. +-- milestone_count — convenience counter; actual milestone rows live in a +-- separate `milestones` table (future migration). + +CREATE TABLE IF NOT EXISTS projects ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + client_id UUID NOT NULL, + title VARCHAR(200) NOT NULL, + description TEXT, + budget_usdc NUMERIC(18,7) NOT NULL CHECK (budget_usdc > 0), + status VARCHAR(20) NOT NULL DEFAULT 'open' + CHECK (status IN ('open','in_progress','completed','cancelled')), + milestone_count INTEGER NOT NULL DEFAULT 0 CHECK (milestone_count >= 0), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Index for the most common query patterns +CREATE INDEX IF NOT EXISTS idx_projects_client_id + ON projects (client_id); + +CREATE INDEX IF NOT EXISTS idx_projects_status + ON projects (status); + +CREATE INDEX IF NOT EXISTS idx_projects_client_status + ON projects (client_id, status); + +CREATE INDEX IF NOT EXISTS idx_projects_created_at + ON projects (created_at DESC); + +-- Automatically update updated_at on every row change. +-- Requires the update_updated_at_column() trigger function from 001-create-tables.sql. +-- If that function does not exist yet, create it with: +-- +-- CREATE OR REPLACE FUNCTION update_updated_at_column() +-- RETURNS TRIGGER AS $$ +-- BEGIN +-- NEW.updated_at = NOW(); +-- RETURN NEW; +-- END; +-- $$ language 'plpgsql'; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.routines + WHERE routine_name = 'update_updated_at_column' + AND routine_type = 'FUNCTION' + ) THEN + EXECUTE $func$ + CREATE FUNCTION update_updated_at_column() + RETURNS TRIGGER AS $inner$ + BEGIN + NEW.updated_at = NOW(); + RETURN NEW; + END; + $inner$ LANGUAGE plpgsql + $func$; + END IF; +END $$; + +CREATE TRIGGER set_projects_updated_at + BEFORE UPDATE ON projects + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); \ No newline at end of file