diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5400e92 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,47 @@ +# Auto detect text files and perform LF normalization +* text=auto + +# Explicitly declare text files you want to always be normalized and converted to native line endings on checkout +*.ts text eol=lf +*.tsx text eol=lf +*.js text eol=lf +*.jsx text eol=lf +*.mjs text eol=lf +*.cjs text eol=lf +*.json text eol=lf +*.md text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.toml text eol=lf +*.css text eol=lf +*.scss text eol=lf +*.html text eol=lf +*.xml text eol=lf +*.svg text eol=lf +*.sh text eol=lf + +# Lock files should maintain LF +package-lock.json text eol=lf +bun.lockb binary +bun.lock text eol=lf +yarn.lock text eol=lf +pnpm-lock.yaml text eol=lf + +# Denote all files that are truly binary and should not be modified +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.woff binary +*.woff2 binary +*.ttf binary +*.eot binary +*.pdf binary +*.zip binary +*.gz binary + +# Windows-specific files +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf diff --git a/.gitignore b/.gitignore index 99bf5af..097f9be 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,17 @@ yarn-error.log* *storybook.log storybook-static + +# IDE +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +.idea +*.swp +*.swo +*~ + +# OS +Thumbs.db diff --git a/apps/backend/.env.example b/apps/backend/.env.example new file mode 100644 index 0000000..17887af --- /dev/null +++ b/apps/backend/.env.example @@ -0,0 +1,15 @@ +# Database Configuration +DATABASE_URL=postgresql://user:password@localhost:5432/eventer + +# Supabase Configuration +SUPABASE_URL=https://your-project.supabase.co +SUPABASE_KEY=your-anon-key-here + +# Server Configuration +PORT=4000 +NODE_ENV=development + +# CORS Configuration +# In development: http://localhost:3000 +# In production: https://your-domain.com +CORS_ORIGIN=http://localhost:3000 diff --git a/apps/backend/Dockerfile b/apps/backend/Dockerfile index d631cbe..8dc79bc 100644 --- a/apps/backend/Dockerfile +++ b/apps/backend/Dockerfile @@ -1,19 +1,36 @@ -FROM oven/bun +FROM oven/bun:1 AS deps +WORKDIR /app + +# Copy workspace manifests for dependency caching +COPY package.json bun.lockb turbo.json ./ +COPY apps/backend/package.json ./apps/backend/ +COPY packages/typescript-config/package.json ./packages/typescript-config/ +RUN bun install --frozen-lockfile --production=false + +FROM oven/bun:1 AS runner WORKDIR /app -COPY package.json . +RUN addgroup --system --gid 1001 appgroup && \ + adduser --system --uid 1001 --ingroup appgroup appuser -RUN bun install --production +COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app/apps/backend/node_modules ./apps/backend/node_modules -COPY src src -COPY tsconfig.json . -COPY drizzle.config.ts . -COPY ./drizzle ./drizzle +COPY apps/backend/src ./apps/backend/src +COPY apps/backend/tsconfig.json ./apps/backend/ +COPY apps/backend/drizzle.config.ts ./apps/backend/ +COPY apps/backend/drizzle ./apps/backend/drizzle +COPY packages/typescript-config ./packages/typescript-config -# COPY public public +USER appuser -ENV NODE_ENV production -CMD ["bun", "src/index.ts"] +ENV NODE_ENV=production +WORKDIR /app/apps/backend -EXPOSE 8080 \ No newline at end of file +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1 + +CMD ["bun", "src/index.ts"] diff --git a/apps/backend/api/index.ts b/apps/backend/api/index.ts index 7e6ed74..3e5d83c 100644 --- a/apps/backend/api/index.ts +++ b/apps/backend/api/index.ts @@ -1,10 +1,29 @@ -// import type { VercelRequest, VercelResponse } from "@vercel/node"; +import type { VercelRequest, VercelResponse } from "@vercel/node"; import { app } from "../src"; -export const config = { - runtime: "edge", // Required for edge deployment -}; +export default async function handler(req: VercelRequest, res: VercelResponse) { + // Convert Vercel request to Web Request + const url = `${req.headers["x-forwarded-proto"] || "http"}://${req.headers.host}${req.url}`; + const headers = new Headers(); + Object.entries(req.headers).forEach(([key, value]) => { + if (value) headers.set(key, Array.isArray(value) ? value.join(", ") : value); + }); -export default async function handler(request: Request): Promise { - return app.handle(request); + const request = new Request(url, { + method: req.method, + headers, + body: req.method !== "GET" && req.method !== "HEAD" ? JSON.stringify(req.body) : undefined, + }); + + // Handle with Elysia + const response = await app.handle(request); + + // Convert Web Response to Vercel Response + res.status(response.status); + response.headers.forEach((value, key) => { + res.setHeader(key, value); + }); + + const body = await response.text(); + res.send(body); } diff --git a/apps/backend/drizzle/0002_tough_nextwave.sql b/apps/backend/drizzle/0002_tough_nextwave.sql new file mode 100644 index 0000000..5e2a7b5 --- /dev/null +++ b/apps/backend/drizzle/0002_tough_nextwave.sql @@ -0,0 +1,3 @@ +ALTER TABLE "events" ADD COLUMN "created_at" timestamp DEFAULT now() NOT NULL;--> statement-breakpoint +ALTER TABLE "events" ADD COLUMN "updated_at" timestamp DEFAULT now() NOT NULL;--> statement-breakpoint +ALTER TABLE "agenda" ADD CONSTRAINT "agenda_event_id_events_id_fk" FOREIGN KEY ("event_id") REFERENCES "public"."events"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/apps/backend/drizzle/meta/0002_snapshot.json b/apps/backend/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..09f8820 --- /dev/null +++ b/apps/backend/drizzle/meta/0002_snapshot.json @@ -0,0 +1,224 @@ +{ + "id": "c6096878-3f40-4d48-9742-04f69fc39bbc", + "prevId": "ef8332c8-f25a-4dd8-8f56-785c9155e4cf", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.agenda": { + "name": "agenda", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "end": { + "name": "end", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "person_in_charge": { + "name": "person_in_charge", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "activity": { + "name": "activity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "remarks": { + "name": "remarks", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "actual_end_time": { + "name": "actual_end_time", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "agenda_event_id_events_id_fk": { + "name": "agenda_event_id_events_id_fk", + "tableFrom": "agenda", + "tableTo": "events", + "columnsFrom": ["event_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start_date": { + "name": "start_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "end_date": { + "name": "end_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/backend/drizzle/meta/_journal.json b/apps/backend/drizzle/meta/_journal.json index f06c966..38b0d0e 100644 --- a/apps/backend/drizzle/meta/_journal.json +++ b/apps/backend/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1753335669401, "tag": "0001_quiet_hex", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1778555035528, + "tag": "0002_tough_nextwave", + "breakpoints": true } ] } diff --git a/apps/backend/package.json b/apps/backend/package.json index 24899ac..9c08e44 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -4,7 +4,7 @@ "scripts": { "dev": "bun --watch src/index.ts", "dev:cf": "wrangler dev", - "build": "bun build src/index.ts --outdir ./dist --target=node", + "build": "bun build src/index.ts --outdir ./dist --target=node --minify --external @supabase/supabase-js --external postgres --external drizzle-orm", "start": "bun run dist/index.js", "typecheck": "bun tsc --noEmit", "db:migrate": "drizzle-kit migrate --config=drizzle.config.ts", diff --git a/apps/backend/src/env.ts b/apps/backend/src/env.ts index 2fcdacc..0542e76 100644 --- a/apps/backend/src/env.ts +++ b/apps/backend/src/env.ts @@ -9,6 +9,7 @@ const envSchema = z.object({ SUPABASE_URL: z.string().default(""), SUPABASE_KEY: z.string().default(""), CORS_ORIGIN: z.string().default("http://localhost:3000"), + CORS_ORIGINS: z.string().optional(), }); export const env = envSchema.parse(process.env ?? import.meta.env); diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index be6cbb5..42106dd 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -10,28 +10,27 @@ import { userRouter } from "./modules/user"; // Helper function to conditionally add swagger const conditionalSwagger = () => { - // Check if we're in Cloudflare Workers environment (caches is a global in Workers) + // Skip swagger in production or serverless environments + const isProduction = process.env.NODE_ENV === "production" || process.env.VERCEL === "1"; const isCloudflareWorkers = typeof caches !== "undefined"; - if (isCloudflareWorkers) { - // Return a no-op plugin for Cloudflare Workers + if (isProduction || isCloudflareWorkers) { return new Elysia({ name: "swagger-noop" }); } - // Use swagger in other environments return swagger(); }; const app = new Elysia({ aot: false }) .use( cors({ - // origin: "https://eventer.betich.me", - origin: /.*\.betich\.me$/, - // [ - // /.*\.betich\.me$/, - // process.env.NODE_ENV === "development" ? /localhost:\d+/ : "", - // env.CORS_ORIGIN || "", - // ], + origin: + process.env.NODE_ENV === "production" + ? (env.CORS_ORIGINS ?? "") + .split(",") + .map((o: string) => o.trim()) + .filter(Boolean) + : [env.CORS_ORIGIN, /localhost:\d+/], credentials: true, preflight: true, methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], @@ -43,9 +42,20 @@ const app = new Elysia({ aot: false }) .use(userRouter) .use(agendaRouter) .use(authRouter) - .onRequest(({ set }) => { + .onRequest(({ request, set }) => { set.headers["access-control-allow-credentials"] = "true"; - // set.headers["access-control-allow-origin"] = "https://eventer.betich.me"; + + // Force HTTPS in production + if (process.env.NODE_ENV === "production") { + const proto = request.headers.get("x-forwarded-proto"); + if (proto && proto !== "https") { + const url = new URL(request.url); + url.protocol = "https:"; + set.status = 301; + set.headers.location = url.toString(); + return; + } + } }); // .group("/api", (app) => // app @@ -56,9 +66,9 @@ const app = new Elysia({ aot: false }) // .use(authRouter) // ); -// Only listen when not in Cloudflare Workers environment -const isCloudflareWorkers = typeof caches !== "undefined"; -if (!isCloudflareWorkers) { +// Only listen in local development (not on Vercel or Cloudflare Workers) +const isServerless = typeof caches !== "undefined" || process.env.VERCEL === "1"; +if (!isServerless) { app.listen( { // hostname: process.env.NODE_ENV === "production" ? "0.0.0.0" : "localhost", diff --git a/apps/backend/src/infrastructure/db/schema/agenda.ts b/apps/backend/src/infrastructure/db/schema/agenda.ts index 236169b..1c40168 100644 --- a/apps/backend/src/infrastructure/db/schema/agenda.ts +++ b/apps/backend/src/infrastructure/db/schema/agenda.ts @@ -1,13 +1,16 @@ import { integer, pgTable as table, text } from "drizzle-orm/pg-core"; +import { events } from "./events"; export const agenda = table("agenda", { - id: text("id").primaryKey(), // UUID - eventId: text("event_id").notNull(), // FK to event - start: text("start").notNull(), // e.g., "09:00" - end: text("end").notNull(), // e.g., "10:30" - personincharge: text("person_in_charge").notNull(), // e.g., "John Doe" - duration: integer("duration").notNull(), // in minutes - activity: text("activity").notNull(), // e.g., "Keynote" + id: text("id").primaryKey(), + eventId: text("event_id") + .notNull() + .references(() => events.id, { onDelete: "cascade" }), + start: text("start").notNull(), + end: text("end").notNull(), + personincharge: text("person_in_charge").notNull(), + duration: integer("duration").notNull(), + activity: text("activity").notNull(), remarks: text("remarks").default(""), - actualEndTime: text("actual_end_time"), // Actual end time as ISO string when session is ended + actualEndTime: text("actual_end_time"), }); diff --git a/apps/backend/src/infrastructure/db/schema/events.ts b/apps/backend/src/infrastructure/db/schema/events.ts index ca13d8e..ee1c890 100644 --- a/apps/backend/src/infrastructure/db/schema/events.ts +++ b/apps/backend/src/infrastructure/db/schema/events.ts @@ -8,4 +8,6 @@ export const events = table("events", { location: text("location").notNull(), description: text("description"), createdBy: text("created_by").notNull(), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), }); diff --git a/apps/backend/src/modules/agenda/agenda.route.ts b/apps/backend/src/modules/agenda/agenda.route.ts index fb3f10b..6a97209 100644 --- a/apps/backend/src/modules/agenda/agenda.route.ts +++ b/apps/backend/src/modules/agenda/agenda.route.ts @@ -1,5 +1,6 @@ import { Elysia, t } from "elysia"; import { db } from "#backend/infrastructure/db"; +import { rateLimit, rateLimitPresets } from "#backend/shared/middleware/rate-limit.middleware"; import { AgendaListResponseSchema, AgendaSchema, @@ -24,6 +25,13 @@ const agendaRepository = new AgendaRepository(db); //TODO : Implement useGetAgenda(eventId, currentDay) export const agendaRouter = new Elysia({ prefix: "/api/agenda" }) + // Apply moderate rate limiting to agenda endpoints (30 requests per minute) + .use( + rateLimit({ + ...rateLimitPresets.moderate, + message: "Too many requests to agenda API. Please slow down.", + }) + ) .get( "/timer", diff --git a/apps/backend/src/modules/agenda/dtos/update-agenda.dto.ts b/apps/backend/src/modules/agenda/dtos/update-agenda.dto.ts index e85ad17..efcfd40 100644 --- a/apps/backend/src/modules/agenda/dtos/update-agenda.dto.ts +++ b/apps/backend/src/modules/agenda/dtos/update-agenda.dto.ts @@ -1,16 +1,15 @@ import { type Static, Type } from "@sinclair/typebox"; export const updateAgendaDTO = Type.Object({ - id: Type.String({ format: "uuid", description: "Invalid agenda ID" }), // Required to know which agenda to update - startTime: Type.Optional(Type.String({ format: "date-time" })), - endTime: Type.Optional(Type.String({ format: "date-time" })), + id: Type.String({ format: "uuid", description: "Invalid agenda ID" }), + start: Type.Optional(Type.String()), + end: Type.Optional(Type.String()), activity: Type.Optional(Type.String({ minLength: 1, description: "Activity is required" })), - remark: Type.Optional( + remarks: Type.Optional( Type.Union([Type.String({ maxLength: 500, description: "Remark too long" }), Type.Null()]) ), - picUserId: Type.Optional(Type.String({ format: "uuid", description: "Invalid user ID" })), - location: Type.Optional(Type.String()), - actualEndTime: Type.Optional(Type.Union([Type.String({ format: "date-time" }), Type.Null()])), + personincharge: Type.Optional(Type.String()), + actualEndTime: Type.Optional(Type.Union([Type.String(), Type.Null()])), }); export type UpdateAgendaDTO = Static; diff --git a/apps/backend/src/modules/auth/auth.route.test.ts b/apps/backend/src/modules/auth/auth.route.test.ts new file mode 100644 index 0000000..9a83203 --- /dev/null +++ b/apps/backend/src/modules/auth/auth.route.test.ts @@ -0,0 +1,360 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// ─── Hoist mock stubs so they are available inside vi.mock() factories ──────── +// vi.mock() calls are hoisted to the top of the file by Vitest, so any +// variables referenced inside a factory must also be hoisted via vi.hoisted(). + +const { mockGetUser, mockSetSession, mockFind, mockFindByEmail, mockCreate } = vi.hoisted(() => ({ + mockGetUser: vi.fn(), + mockSetSession: vi.fn(), + mockFind: vi.fn(), + mockFindByEmail: vi.fn(), + mockCreate: vi.fn(), +})); + +// ─── Mock all external dependencies before importing the module under test ─── + +vi.mock("#backend/infrastructure/db", () => ({ + db: {}, +})); + +vi.mock("#backend/infrastructure/db/supabase", () => ({ + supabase: { + auth: { + getUser: mockGetUser, + setSession: mockSetSession, + }, + }, +})); + +vi.mock("#backend/modules/user/user.repository", () => ({ + UserRepository: vi.fn().mockImplementation(() => ({ + find: mockFind, + findByEmail: mockFindByEmail, + create: mockCreate, + })), +})); + +// Bypass the rate-limit plugin so tests are not throttled. +vi.mock("../../shared/middleware/rate-limit.middleware", async () => { + const { Elysia } = await import("elysia"); + return { + rateLimit: vi.fn().mockReturnValue(new Elysia()), + rateLimitPresets: { strict: { max: 5, duration: 60000 } }, + }; +}); + +// ─── Import the router under test (must be after all vi.mock() calls) ──────── +import { authRouter } from "./auth.route"; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** + * Send a request to the Elysia router and return the parsed JSON response. + */ +async function handleRequest( + method: string, + path: string, + options?: { body?: unknown; headers?: Record } +) { + const request = new Request(`http://localhost${path}`, { + method, + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + body: options?.body !== undefined ? JSON.stringify(options.body) : undefined, + }); + + const response = await authRouter.handle(request); + + let data: unknown; + try { + data = await response.json(); + } catch { + data = null; + } + + return { response, data }; +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe("Auth Router – GET /api/auth/session", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return { user: null } when no session cookie is present", async () => { + // Default mock returns undefined → reveals the missing null-guard bug. + const { response, data } = await handleRequest("GET", "/api/auth/session"); + + expect(response.status).toBe(200); + expect(data).toEqual({ user: null }); + }); + + it("should return { user: null } when supabase returns an error", async () => { + mockGetUser.mockResolvedValueOnce({ + data: { user: null }, + error: new Error("invalid JWT"), + }); + + const { response, data } = await handleRequest("GET", "/api/auth/session", { + headers: { Cookie: "session=bad-token" }, + }); + + expect(response.status).toBe(200); + expect(data).toEqual({ user: null }); + }); + + it("should return { user: null } when the user is not found in the database", async () => { + mockGetUser.mockResolvedValueOnce({ + data: { user: { id: "supabase-uid-1" } }, + error: null, + }); + mockFind.mockResolvedValueOnce(null); + + const { response, data } = await handleRequest("GET", "/api/auth/session", { + headers: { Cookie: "session=valid-token" }, + }); + + expect(response.status).toBe(200); + expect(data).toEqual({ user: null }); + }); + + it("should return the user object when the session is valid and the user exists", async () => { + mockGetUser.mockResolvedValueOnce({ + data: { user: { id: "supabase-uid-1" } }, + error: null, + }); + mockFind.mockResolvedValueOnce({ + id: "db-user-id", + email: "alice@example.com", + name: "Alice", + avatarUrl: "https://cdn.example.com/alice.jpg", + }); + + const { response, data } = await handleRequest("GET", "/api/auth/session", { + headers: { Cookie: "session=valid-token" }, + }); + + expect(response.status).toBe(200); + expect((data as Record).user).toMatchObject({ + id: "db-user-id", + email: "alice@example.com", + name: "Alice", + avatar_url: "https://cdn.example.com/alice.jpg", + }); + }); + + it("should omit avatar_url from the response when the user has no avatar", async () => { + mockGetUser.mockResolvedValueOnce({ + data: { user: { id: "supabase-uid-2" } }, + error: null, + }); + mockFind.mockResolvedValueOnce({ + id: "db-user-id-2", + email: "bob@example.com", + name: "Bob", + avatarUrl: null, + }); + + const { data } = await handleRequest("GET", "/api/auth/session", { + headers: { Cookie: "session=valid-token" }, + }); + + const user = (data as Record).user as Record; + expect(user).not.toHaveProperty("avatar_url"); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── + +describe("Auth Router – POST /api/auth/callback", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const validBody = { + access_token: "google-access-token-abc", + refresh_token: "google-refresh-token-xyz", + }; + + const mockSupabaseSession = { access_token: "new-session-token" }; + + const mockSupabaseUser = { + id: "supabase-uid-99", + email: "carol@example.com", + user_metadata: { + full_name: "Carol", + avatar_url: "https://cdn.example.com/carol.jpg", + picture: null, + }, + }; + + it("should create a new user and return success when the user does not exist yet", async () => { + mockSetSession.mockResolvedValueOnce({ + data: { session: mockSupabaseSession, user: mockSupabaseUser }, + error: null, + }); + mockFindByEmail.mockResolvedValueOnce(null); // user doesn't exist + mockCreate.mockResolvedValueOnce({}); + + const { response, data } = await handleRequest("POST", "/api/auth/callback", { + body: validBody, + }); + + expect(response.status).toBe(200); + const res = data as Record; + expect(res.success).toBe(true); + expect((res.user as Record).email).toBe("carol@example.com"); + + expect(mockCreate).toHaveBeenCalledOnce(); + expect(mockCreate).toHaveBeenCalledWith({ + id: "supabase-uid-99", + email: "carol@example.com", + name: "Carol", + avatar_url: "https://cdn.example.com/carol.jpg", + }); + }); + + it("should skip user creation when the user already exists", async () => { + mockSetSession.mockResolvedValueOnce({ + data: { session: mockSupabaseSession, user: mockSupabaseUser }, + error: null, + }); + mockFindByEmail.mockResolvedValueOnce({ + id: "existing-db-id", + email: "carol@example.com", + }); + + const { response, data } = await handleRequest("POST", "/api/auth/callback", { + body: validBody, + }); + + expect(response.status).toBe(200); + expect((data as Record).success).toBe(true); + expect(mockCreate).not.toHaveBeenCalled(); + }); + + it("should fall back to picture when avatar_url is absent in user_metadata", async () => { + const userWithPictureOnly = { + ...mockSupabaseUser, + user_metadata: { + full_name: "Carol", + avatar_url: null, + picture: "https://cdn.example.com/carol-picture.jpg", + }, + }; + + mockSetSession.mockResolvedValueOnce({ + data: { session: mockSupabaseSession, user: userWithPictureOnly }, + error: null, + }); + mockFindByEmail.mockResolvedValueOnce(null); + mockCreate.mockResolvedValueOnce({}); + + const { data } = await handleRequest("POST", "/api/auth/callback", { + body: validBody, + }); + + const user = (data as Record).user as Record; + expect(user.avatar_url).toBe("https://cdn.example.com/carol-picture.jpg"); + }); + + it("should not include avatar_url in the response when both avatar_url and picture are absent", async () => { + const userNoAvatar = { + ...mockSupabaseUser, + user_metadata: { full_name: "Carol", avatar_url: null, picture: null }, + }; + + mockSetSession.mockResolvedValueOnce({ + data: { session: mockSupabaseSession, user: userNoAvatar }, + error: null, + }); + mockFindByEmail.mockResolvedValueOnce(null); + mockCreate.mockResolvedValueOnce({}); + + const { data } = await handleRequest("POST", "/api/auth/callback", { + body: validBody, + }); + + const user = (data as Record).user as Record; + expect(user).not.toHaveProperty("avatar_url"); + }); + + it("should respond 500 when supabase.setSession returns an error", async () => { + mockSetSession.mockResolvedValueOnce({ + data: { session: null, user: null }, + error: new Error("Token expired"), + }); + + const { response } = await handleRequest("POST", "/api/auth/callback", { + body: validBody, + }); + + expect(response.status).toBe(500); + }); + + it("should respond 500 when the supabase user has no email", async () => { + mockSetSession.mockResolvedValueOnce({ + data: { + session: mockSupabaseSession, + user: { ...mockSupabaseUser, email: null }, + }, + error: null, + }); + + const { response } = await handleRequest("POST", "/api/auth/callback", { + body: validBody, + }); + + expect(response.status).toBe(500); + }); + + /** + * Validates the Elysia body schema (AuthCallbackSchema) rejects incomplete + * payloads. Elysia returns 400 (not 422) for body validation failures. + */ + it("should respond 400 when refresh_token is missing from the request body", async () => { + const { response } = await handleRequest("POST", "/api/auth/callback", { + body: { access_token: "only-access" }, + }); + + expect(response.status).toBe(400); + }); + + it("should respond 400 when the request body is empty", async () => { + const { response } = await handleRequest("POST", "/api/auth/callback", { + body: {}, + }); + + expect(response.status).toBe(400); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── + +describe("Auth Router – POST /api/auth/logout", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return 200 and success message when a session cookie is present", async () => { + const { response, data } = await handleRequest("POST", "/api/auth/logout", { + headers: { Cookie: "session=active-token" }, + }); + + expect(response.status).toBe(200); + const res = data as Record; + expect(res.success).toBe(true); + expect(res.message).toBe("Logged out successfully"); + }); + + it("should return 400 when no session cookie is present", async () => { + const { response, data } = await handleRequest("POST", "/api/auth/logout"); + + expect(response.status).toBe(400); + expect((data as Record).error).toBe("No session to logout"); + }); +}); diff --git a/apps/backend/src/modules/auth/auth.route.ts b/apps/backend/src/modules/auth/auth.route.ts index 1e49d63..d1aa31f 100644 --- a/apps/backend/src/modules/auth/auth.route.ts +++ b/apps/backend/src/modules/auth/auth.route.ts @@ -2,6 +2,7 @@ import { Elysia } from "elysia"; import { db } from "#backend/infrastructure/db"; import { supabase } from "#backend/infrastructure/db/supabase"; import { UserRepository } from "#backend/modules/user/user.repository"; +import { rateLimit, rateLimitPresets } from "../../shared/middleware/rate-limit.middleware"; import { AuthCallbackSchema, AuthResponseSchema, @@ -13,14 +14,21 @@ import { const userRepository = new UserRepository(db); export const authRouter = new Elysia({ prefix: "/api/auth" }) + // Apply strict rate limiting to all auth endpoints (5 requests per minute) + .use( + rateLimit({ + ...rateLimitPresets.strict, + message: "Too many authentication attempts. Please try again later.", + }) + ) .get( "/session", async ({ cookie: { session } }) => { - if (!session) { + if (!session?.value) { return { user: null }; } - const { data, error } = await supabase.auth.getUser(session.value); + const { data, error } = await supabase.auth.getUser(session.value as string); if (error || !data.user) { return { user: null }; } @@ -109,7 +117,7 @@ export const authRouter = new Elysia({ prefix: "/api/auth" }) .post( "/logout", ({ cookie: { session }, set }) => { - if (!session) { + if (!session?.value) { set.status = 400; return { error: "No session to logout" }; } diff --git a/apps/backend/src/modules/event/event.entity.spec.ts b/apps/backend/src/modules/event/event.entity.spec.ts new file mode 100644 index 0000000..3d084bc --- /dev/null +++ b/apps/backend/src/modules/event/event.entity.spec.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { EventEntity } from "./event.entity"; +import type { EventType } from "./event.model"; + +const makeEvent = (overrides?: Partial): EventType => ({ + id: "evt-1", + name: "Test Conference", + startDate: new Date("2099-01-10"), + endDate: new Date("2099-01-12"), + location: "Bangkok", + description: null, + createdBy: "user-1", + createdAt: new Date("2025-01-01"), + updatedAt: new Date("2025-01-01"), + ...overrides, +}); + +describe("EventEntity", () => { + describe("isUpcoming()", () => { + it("returns true when event starts in the future", () => { + const entity = new EventEntity(makeEvent({ startDate: new Date("2099-01-10") })); + expect(entity.isUpcoming()).toBe(true); + }); + + it("returns false when event started in the past", () => { + const entity = new EventEntity(makeEvent({ startDate: new Date("2000-01-01") })); + expect(entity.isUpcoming()).toBe(false); + }); + }); + + describe("durationIndays()", () => { + it("returns correct duration for a 2-day event", () => { + const entity = new EventEntity( + makeEvent({ + startDate: new Date("2099-01-10"), + endDate: new Date("2099-01-12"), + }) + ); + expect(entity.durationIndays()).toBe(2); + }); + + it("returns 0 for a same-day event", () => { + const d = new Date("2099-01-10"); + const entity = new EventEntity(makeEvent({ startDate: d, endDate: d })); + expect(entity.durationIndays()).toBe(0); + }); + + it("returns 1 for an overnight event", () => { + const entity = new EventEntity( + makeEvent({ + startDate: new Date("2099-01-10"), + endDate: new Date("2099-01-11"), + }) + ); + expect(entity.durationIndays()).toBe(1); + }); + }); + + describe("getSummary()", () => { + it("returns 'name @ location' format", () => { + const entity = new EventEntity(makeEvent({ name: "My Event", location: "Chiang Mai" })); + expect(entity.getSummary()).toBe("My Event @ Chiang Mai"); + }); + }); +}); diff --git a/apps/backend/src/modules/event/event.repository.ts b/apps/backend/src/modules/event/event.repository.ts index daa60cf..d2e76d4 100644 --- a/apps/backend/src/modules/event/event.repository.ts +++ b/apps/backend/src/modules/event/event.repository.ts @@ -11,17 +11,24 @@ class EventRepository { async create(event: CreateEventDTO): Promise { try { const id = uuidv4(); - await this.db.insert(events).values({ - ...event, - id, - startDate: new Date(event.startDate), // Convert string to Date for database - endDate: new Date(event.endDate), // Convert string to Date for database - }); + const startDate = new Date(event.startDate); + const endDate = new Date(event.endDate); + const now = new Date(); - return { - ...event, - id, - }; + const [inserted] = await this.db + .insert(events) + .values({ + ...event, + id, + startDate, + endDate, + createdAt: now, + updatedAt: now, + }) + .returning(); + + if (!inserted) throw new Error("Failed to insert event"); + return inserted; } catch (error) { if (error instanceof Error) { console.error("Error creating event:", error.message); @@ -49,10 +56,10 @@ class EventRepository { async update(id: string, event: UpdateEventDTO): Promise { try { - const updateData = { + const updateData: Partial = { ...event, - ...(event.startDate && { startDate: new Date(event.startDate) }), // Convert string to Date if provided - ...(event.endDate && { endDate: new Date(event.endDate) }), // Convert string to Date if provided + startDate: new Date(event.startDate), + endDate: new Date(event.endDate), }; const updated = await this.db diff --git a/apps/backend/src/modules/event/event.route.ts b/apps/backend/src/modules/event/event.route.ts index e473779..c182a72 100644 --- a/apps/backend/src/modules/event/event.route.ts +++ b/apps/backend/src/modules/event/event.route.ts @@ -1,6 +1,7 @@ -import { Elysia } from "elysia"; +import { Elysia, t } from "elysia"; import { db } from "#backend/infrastructure/db"; import { authMiddleware, optionalAuthMiddleware } from "#backend/shared/middleware/auth.middleware"; +import { rateLimit, rateLimitPresets } from "#backend/shared/middleware/rate-limit.middleware"; import { CreateEventSchema, EventListResponseSchema, @@ -8,12 +9,37 @@ import { EventSchema, } from "#backend/shared/schemas"; import { EventRepository } from "./event.repository"; -import { createEvent, listEvents } from "./services/crud-event.service"; +import { createEvent, getEventById, listEvents } from "./services/crud-event.service"; const eventRepository = new EventRepository(db); export const eventRouter = new Elysia({ prefix: "/api/event" }) + // Apply moderate rate limiting to event endpoints (30 requests per minute) + .use( + rateLimit({ + ...rateLimitPresets.moderate, + message: "Too many requests to event API. Please slow down.", + }) + ) .use(optionalAuthMiddleware) + .get( + "/:id", + async ({ params, set }) => { + const event = await getEventById(eventRepository, params.id); + if (!event) { + set.status = 404; + return { error: "Event not found" }; + } + return event; + }, + { + params: t.Object({ id: t.String() }), + response: { + 200: EventSchema, + 404: t.Object({ error: t.String() }), + }, + } + ) .get( "/", async ({ query }) => { @@ -40,13 +66,13 @@ export const eventRouter = new Elysia({ prefix: "/api/event" }) .use(authMiddleware) .post( "/", - async ({ body }) => { + async ({ body, user }) => { const transformedData = { ...body, - startDate: body.startDate, // Keep as string for database storage - endDate: body.endDate, // Keep as string for database storage + startDate: body.startDate, + endDate: body.endDate, description: body.description ?? null, - createdBy: "user_placeholder", // TODO: Fix auth context injection + createdBy: user.id, }; return await createEvent(eventRepository, transformedData); }, diff --git a/apps/backend/src/modules/event/services/crud-event.service.spec.ts b/apps/backend/src/modules/event/services/crud-event.service.spec.ts new file mode 100644 index 0000000..71ffb97 --- /dev/null +++ b/apps/backend/src/modules/event/services/crud-event.service.spec.ts @@ -0,0 +1,184 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { EventType } from "../event.model"; +import type { EventRepository } from "../event.repository"; +import { createEvent, getEventById, listEvents } from "./crud-event.service"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const makeEventType = (overrides?: Partial): EventType => ({ + id: "evt-1", + name: "Test Conference", + startDate: new Date("2099-01-10"), + endDate: new Date("2099-01-12"), + location: "Bangkok", + description: null, + createdBy: "user-1", + createdAt: new Date("2025-01-01"), + updatedAt: new Date("2025-01-01"), + ...overrides, +}); + +const makeRepo = (): EventRepository => + ({ + create: vi.fn(), + read: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + list: vi.fn(), + }) as unknown as EventRepository; + +// ── createEvent ─────────────────────────────────────────────────────────────── + +describe("createEvent", () => { + let repo: EventRepository; + + beforeEach(() => { + repo = makeRepo(); + }); + + it("calls repository.create with the supplied data", async () => { + const input = { + name: "My Event", + startDate: "2099-01-10T00:00:00.000Z", + endDate: "2099-01-12T00:00:00.000Z", + location: "Bangkok", + description: null, + createdBy: "user-1", + }; + const stored = makeEventType(); + vi.mocked(repo.create).mockResolvedValue(stored); + + await createEvent(repo, input); + + expect(repo.create).toHaveBeenCalledOnce(); + expect(repo.create).toHaveBeenCalledWith(input); + }); + + it("returns the event record from the repository (including createdAt/updatedAt)", async () => { + const stored = makeEventType({ + id: "evt-new", + createdAt: new Date("2025-05-01"), + updatedAt: new Date("2025-05-01"), + }); + vi.mocked(repo.create).mockResolvedValue(stored); + + const result = await createEvent(repo, { + name: "My Event", + startDate: "2099-01-10T00:00:00.000Z", + endDate: "2099-01-12T00:00:00.000Z", + location: "Bangkok", + description: null, + createdBy: "user-1", + }); + + expect(result.id).toBe("evt-new"); + expect(result.createdAt).toBeDefined(); + expect(result.updatedAt).toBeDefined(); + }); + + it("propagates errors thrown by the repository", async () => { + vi.mocked(repo.create).mockRejectedValue(new Error("DB down")); + + await expect( + createEvent(repo, { + name: "My Event", + startDate: "2099-01-10T00:00:00.000Z", + endDate: "2099-01-12T00:00:00.000Z", + location: "Bangkok", + description: null, + createdBy: "user-1", + }) + ).rejects.toThrow("DB down"); + }); +}); + +// ── getEventById ────────────────────────────────────────────────────────────── + +describe("getEventById", () => { + let repo: EventRepository; + + beforeEach(() => { + repo = makeRepo(); + }); + + it("returns the event when found", async () => { + const event = makeEventType(); + vi.mocked(repo.read).mockResolvedValue(event); + + const result = await getEventById(repo, "evt-1"); + + expect(result).toEqual(event); + expect(repo.read).toHaveBeenCalledWith("evt-1"); + }); + + it("returns null when the event does not exist", async () => { + vi.mocked(repo.read).mockResolvedValue(null); + + const result = await getEventById(repo, "non-existent"); + + expect(result).toBeNull(); + }); +}); + +// ── listEvents ──────────────────────────────────────────────────────────────── + +describe("listEvents", () => { + let repo: EventRepository; + + beforeEach(() => { + repo = makeRepo(); + }); + + it("returns all events when no filters applied", async () => { + const events = [makeEventType(), makeEventType({ id: "evt-2", name: "Second Event" })]; + vi.mocked(repo.list).mockResolvedValue(events); + + const result = await listEvents(repo); + + expect(result).toHaveLength(2); + }); + + it("filters by name (case-insensitive substring)", async () => { + vi.mocked(repo.list).mockResolvedValue([ + makeEventType({ name: "Bangkok Summit" }), + makeEventType({ id: "evt-2", name: "Chiang Mai Festival" }), + ]); + + const result = await listEvents(repo, { name: "bangkok" }); + + expect(result).toHaveLength(1); + expect(result[0]?.name).toBe("Bangkok Summit"); + }); + + it("filters by location (case-insensitive substring)", async () => { + vi.mocked(repo.list).mockResolvedValue([ + makeEventType({ location: "Bangkok Arena" }), + makeEventType({ id: "evt-2", location: "Chiang Mai Hall" }), + ]); + + const result = await listEvents(repo, { location: "chiang" }); + + expect(result).toHaveLength(1); + expect(result[0]?.location).toBe("Chiang Mai Hall"); + }); + + it("filters by startDate — excludes events before the given date", async () => { + vi.mocked(repo.list).mockResolvedValue([ + makeEventType({ startDate: new Date("2099-03-01") }), + makeEventType({ id: "evt-2", startDate: new Date("2099-01-01") }), + ]); + + const result = await listEvents(repo, { startDate: "2099-02-01T00:00:00.000Z" }); + + expect(result).toHaveLength(1); + expect(result[0]?.startDate).toEqual(new Date("2099-03-01")); + }); + + it("returns empty array when no events match the filter", async () => { + vi.mocked(repo.list).mockResolvedValue([makeEventType({ name: "Bangkok Summit" })]); + + const result = await listEvents(repo, { name: "nonexistent" }); + + expect(result).toHaveLength(0); + }); +}); diff --git a/apps/backend/src/modules/event/services/crud-event.service.ts b/apps/backend/src/modules/event/services/crud-event.service.ts index 6b2c7d8..07757cb 100644 --- a/apps/backend/src/modules/event/services/crud-event.service.ts +++ b/apps/backend/src/modules/event/services/crud-event.service.ts @@ -21,6 +21,7 @@ export async function listEvents( startDate?: string; endDate?: string; location?: string; + createdBy?: string; } ): Promise { // TODO implement query CRUD @@ -30,9 +31,8 @@ export async function listEvents( // in memory filtering let filtered = allEvents; if (query?.name) { - filtered = filtered.filter((event) => - event.name.toLowerCase().includes(query.name.toLowerCase()) - ); + const name = query.name; + filtered = filtered.filter((event) => event.name.toLowerCase().includes(name.toLowerCase())); } if (query?.startDate) { filtered = filtered.filter((event) => event.startDate >= new Date(query.startDate!)); @@ -41,14 +41,16 @@ export async function listEvents( filtered = filtered.filter((event) => event.endDate <= new Date(query.endDate!)); } if (query?.location) { + const location = query.location; + filtered = filtered.filter((event) => + event.location.toLowerCase().includes(location.toLowerCase()) + ); + } + if (query?.createdBy) { + const createdBy = query.createdBy; filtered = filtered.filter((event) => - event.location.toLowerCase().includes(query.location.toLowerCase()) + event.createdBy.toLowerCase().includes(createdBy.toLowerCase()) ); - if (query?.createdBy) { - filtered = filtered.filter((event) => - event.createdBy.toLowerCase().includes(query.createdBy.toLowerCase()) - ); - } } return filtered; } diff --git a/apps/backend/src/modules/home/home.route.ts b/apps/backend/src/modules/home/home.route.ts index 4d414d3..4cb7f71 100644 --- a/apps/backend/src/modules/home/home.route.ts +++ b/apps/backend/src/modules/home/home.route.ts @@ -1,4 +1,6 @@ +import { sql } from "drizzle-orm"; import { Elysia } from "elysia"; +import { db } from "#backend/infrastructure/db"; export const homeRouter = new Elysia() .get("/", () => "Hello Elysia") @@ -7,4 +9,13 @@ export const homeRouter = new Elysia() message: "Welcome to the Eventer API", version: "1.0.0", }; + }) + .get("/health", async ({ set }) => { + try { + await db.execute(sql`SELECT 1`); + return { status: "ok", timestamp: new Date().toISOString() }; + } catch { + set.status = 503; + return { status: "error", timestamp: new Date().toISOString() }; + } }); diff --git a/apps/backend/src/shared/middleware/rate-limit.middleware.ts b/apps/backend/src/shared/middleware/rate-limit.middleware.ts new file mode 100644 index 0000000..726aca7 --- /dev/null +++ b/apps/backend/src/shared/middleware/rate-limit.middleware.ts @@ -0,0 +1,186 @@ +import { Elysia } from "elysia"; + +interface RateLimitConfig { + /** + * Maximum number of requests allowed within the duration window + */ + max: number; + + /** + * Time window in milliseconds + */ + duration: number; + + /** + * Optional custom key generator function + * Defaults to using IP address + */ + generator?: (context: { + request: Request; + headers: Record; + }) => string; + + /** + * Error message when rate limit is exceeded + */ + message?: string; + + /** + * Skip rate limiting based on custom logic + */ + skip?: (context: { request: Request }) => boolean; +} + +interface RateLimitEntry { + count: number; + resetTime: number; +} + +class RateLimiter { + private store: Map = new Map(); + private cleanupInterval: Timer; + + constructor() { + // Clean up expired entries every minute + this.cleanupInterval = setInterval(() => { + const now = Date.now(); + for (const [key, entry] of this.store.entries()) { + if (entry.resetTime < now) { + this.store.delete(key); + } + } + }, 60000); + } + + public check( + key: string, + max: number, + duration: number + ): { allowed: boolean; remaining: number; resetTime: number } { + const now = Date.now(); + const entry = this.store.get(key); + + // No existing entry or entry has expired + if (!entry || entry.resetTime < now) { + const resetTime = now + duration; + this.store.set(key, { count: 1, resetTime }); + return { + allowed: true, + remaining: max - 1, + resetTime, + }; + } + + // Increment count and check if limit exceeded + entry.count++; + this.store.set(key, entry); + + return { + allowed: entry.count <= max, + remaining: Math.max(0, max - entry.count), + resetTime: entry.resetTime, + }; + } + + public destroy() { + clearInterval(this.cleanupInterval); + this.store.clear(); + } +} + +// Global rate limiter instance +const rateLimiter = new RateLimiter(); + +/** + * Rate limiting middleware for Elysia + * + * @example + * ```ts + * app.use(rateLimit({ + * max: 5, // 5 requests + * duration: 60000, // per minute + * })) + * ``` + */ +export function rateLimit(config: RateLimitConfig) { + const { max, duration, generator, message, skip } = config; + + return new Elysia({ name: "rate-limit" }).onBeforeHandle(({ request, set, headers }) => { + // Skip rate limiting if custom skip function returns true + if (skip?.({ request })) { + return; + } + + // Generate unique key for this client + const key = generator ? generator({ request, headers }) : getClientIdentifier(request, headers); + + // Check rate limit + const result = rateLimiter.check(key, max, duration); + + // Set rate limit headers + set.headers["X-RateLimit-Limit"] = max.toString(); + set.headers["X-RateLimit-Remaining"] = result.remaining.toString(); + set.headers["X-RateLimit-Reset"] = new Date(result.resetTime).toISOString(); + + // If limit exceeded, return 429 Too Many Requests + if (!result.allowed) { + set.status = 429; + const retryAfter = Math.ceil((result.resetTime - Date.now()) / 1000); + set.headers["Retry-After"] = retryAfter.toString(); + + throw new Error(message || `Too many requests. Please try again in ${retryAfter} seconds.`); + } + }); +} + +/** + * Get client identifier from request + * Tries to use IP address from various headers, falls back to random identifier + */ +function getClientIdentifier( + request: Request, + headers: Record +): string { + // Try to get IP from common headers + const forwardedFor = headers["x-forwarded-for"]; + if (forwardedFor) { + return forwardedFor.split(",")[0]?.trim() || "unknown"; + } + + const realIp = headers["x-real-ip"]; + if (realIp) { + return realIp; + } + + const cfConnectingIp = headers["cf-connecting-ip"]; + if (cfConnectingIp) { + return cfConnectingIp; + } + + // Fallback to URL+User-Agent combo if no IP available + const userAgent = headers["user-agent"] || "unknown"; + return `${request.url}-${userAgent}`; +} + +/** + * Predefined rate limit configurations + */ +export const rateLimitPresets = { + /** + * Strict rate limit for sensitive operations (auth, password reset, etc.) + * 5 requests per minute + */ + strict: { max: 5, duration: 60000 }, + + /** + * Moderate rate limit for API endpoints + * 30 requests per minute + */ + moderate: { max: 30, duration: 60000 }, + + /** + * Relaxed rate limit for public endpoints + * 100 requests per minute + */ + relaxed: { max: 100, duration: 60000 }, +}; diff --git a/apps/backend/src/shared/schemas.ts b/apps/backend/src/shared/schemas.ts index 4b9acbf..2e5b4c5 100644 --- a/apps/backend/src/shared/schemas.ts +++ b/apps/backend/src/shared/schemas.ts @@ -30,6 +30,8 @@ export const EventSchema = t.Object({ location: t.String(), description: t.Nullable(t.String()), createdBy: t.String(), + createdAt: t.Date(), + updatedAt: t.Date(), }); export const CreateEventSchema = t.Object({ @@ -66,12 +68,11 @@ export const CreateAgendaSchema = t.Object({ export const UpdateAgendaSchema = t.Object({ id: t.String(), eventId: t.Optional(t.String()), - startTime: t.Optional(t.String()), - endTime: t.Optional(t.String()), + start: t.Optional(t.String()), + end: t.Optional(t.String()), activity: t.Optional(t.String()), - remark: t.Optional(t.Nullable(t.String())), - picUserId: t.Optional(t.String()), - location: t.Optional(t.String()), + remarks: t.Optional(t.Nullable(t.String())), + personincharge: t.Optional(t.String()), actualEndTime: t.Optional(t.Nullable(t.String())), }); diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json index 893e690..dd52433 100644 --- a/apps/backend/tsconfig.json +++ b/apps/backend/tsconfig.json @@ -13,7 +13,7 @@ "noImplicitOverride": true, "module": "esnext", "moduleResolution": "bundler", - "lib": ["esnext"], + "lib": ["esnext", "webworker"], "baseUrl": ".", "paths": { "#backend/*": ["./src/*"] diff --git a/apps/backend/vercel.json b/apps/backend/vercel.json index 4d52424..9ea20dd 100644 --- a/apps/backend/vercel.json +++ b/apps/backend/vercel.json @@ -1,9 +1,16 @@ { "$schema": "https://openapi.vercel.sh/vercel.json", "version": 2, + "rewrites": [ + { + "source": "/(.*)", + "destination": "/api/index" + } + ], "functions": { - "api/index.ts": { - "memory": 512 + "api/**/*.ts": { + "memory": 1024, + "maxDuration": 30 } } } diff --git a/apps/backend/vitest.config.ts b/apps/backend/vitest.config.ts index b7bc35e..13371ed 100644 --- a/apps/backend/vitest.config.ts +++ b/apps/backend/vitest.config.ts @@ -5,7 +5,7 @@ export default defineConfig({ test: { globals: true, environment: "node", - include: ["src/**/*.test.ts"], + include: ["src/**/*.test.ts", "src/**/*.spec.ts"], env: { ...config({ path: ".env.test" }).parsed, }, diff --git a/apps/backend/wrangler.toml b/apps/backend/wrangler.toml index f5e8231..77e25f7 100644 --- a/apps/backend/wrangler.toml +++ b/apps/backend/wrangler.toml @@ -2,3 +2,9 @@ name = "eventer-backend" compatibility_date = "2025-07-25" main = "src/index.ts" compatibility_flags = [ "nodejs_compat", "nodejs_compat_populate_process_env" ] + +# Secrets (set via: bunx wrangler secret put ) +# DATABASE_URL — Supabase Transaction Pooler URL (port 6543, ?pgbouncer=true) +# SUPABASE_URL — https://your-project.supabase.co +# SUPABASE_KEY — Supabase anon/service key +# CORS_ORIGINS — comma-separated allowed origins, e.g. https://app.vercel.app diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts new file mode 100644 index 0000000..c2894d5 --- /dev/null +++ b/apps/web/middleware.ts @@ -0,0 +1,19 @@ +import type { NextRequest } from "next/server"; +import { updateSession } from "@/lib/supabase-middleware"; + +export async function middleware(request: NextRequest) { + return await updateSession(request); +} + +export const config = { + matcher: [ + /* + * Match all request paths except for the ones starting with: + * - _next/static (static files) + * - _next/image (image optimization files) + * - favicon.ico (favicon file) + * Feel free to modify this pattern to include more paths. + */ + "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)", + ], +}; diff --git a/apps/web/next.config.js b/apps/web/next.config.js index a795986..3dc019c 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -1,5 +1,41 @@ /** @type {import('next').NextConfig} */ +const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL ?? ""; +const connectSrcExtra = backendUrl ? ` ${backendUrl}` : ""; + const nextConfig = { + async headers() { + return [ + { + source: "/:path*", + headers: [ + { + key: "X-Frame-Options", + value: "DENY", + }, + { + key: "X-Content-Type-Options", + value: "nosniff", + }, + { + key: "X-XSS-Protection", + value: "1; mode=block", + }, + { + key: "Referrer-Policy", + value: "strict-origin-when-cross-origin", + }, + { + key: "Permissions-Policy", + value: "camera=(), microphone=(), geolocation=()", + }, + { + key: "Content-Security-Policy", + value: `default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' https://lh3.googleusercontent.com data:; font-src 'self' data:; connect-src 'self' https://*.supabase.co http://localhost:*${connectSrcExtra};`, + }, + ], + }, + ]; + }, images: { remotePatterns: [ { diff --git a/apps/web/package.json b/apps/web/package.json index b9a3599..70f1ea3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -15,6 +15,8 @@ }, "dependencies": { "@elysiajs/eden": "^1.3.2", + "@supabase/ssr": "^0.8.0", + "@supabase/supabase-js": "^2.97.0", "@t3-oss/env-nextjs": "^0.13.7", "@tailwindcss/postcss": "^4.1.11", "@tanstack/react-query": "^5.83.0", diff --git a/apps/web/src/app/auth/callback/page.tsx b/apps/web/src/app/auth/callback/page.tsx deleted file mode 100644 index b9e97e5..0000000 --- a/apps/web/src/app/auth/callback/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { AuthCallback } from "@/modules/auth/callback/AuthCallback"; - -export default function AuthCallbackPage() { - return ; -} diff --git a/apps/web/src/app/auth/callback/route.ts b/apps/web/src/app/auth/callback/route.ts new file mode 100644 index 0000000..e914a90 --- /dev/null +++ b/apps/web/src/app/auth/callback/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from "next/server"; +import { createClient } from "@/lib/supabase-server"; + +export async function GET(request: Request) { + const requestUrl = new URL(request.url); + const code = requestUrl.searchParams.get("code"); + const origin = requestUrl.origin; + + if (code) { + const supabase = await createClient(); + const { error } = await supabase.auth.exchangeCodeForSession(code); + + if (error) { + console.error("Error exchanging code for session:", error); + return NextResponse.redirect(`${origin}/auth/login?error=${error.message}`); + } + } + + // URL to redirect to after sign in process completes + return NextResponse.redirect(`${origin}/event`); +} diff --git a/apps/web/src/app/event/[id]/page.tsx b/apps/web/src/app/event/[id]/page.tsx index cb82c6b..eb0653e 100644 --- a/apps/web/src/app/event/[id]/page.tsx +++ b/apps/web/src/app/event/[id]/page.tsx @@ -1,29 +1,515 @@ "use client"; -import { useSearchParams } from "next/navigation"; +import { + Bell, + Check, + Clock, + Globe, + Lock, + LogOut, + Menu, + MoreHorizontal, + Search, + X, +} from "lucide-react"; +import Image from "next/image"; +import { useParams, useRouter } from "next/navigation"; +import type React from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Button } from "@/components/atoms/button"; +import { Input } from "@/components/atoms/input"; import { useGetAgenda } from "@/hooks/use-get-agenda"; +import { useGetEvent } from "@/hooks/use-get-event"; +import { useSession } from "@/hooks/use-session"; +import { useSessionManager } from "@/hooks/use-session-manager"; +import { createClient } from "@/lib/supabase"; +import AgendaSection from "../../../modules/event/AgendaSection"; +import ExtraSection from "../../../modules/event/ExtraSection"; +import GanttSection from "../../../modules/event/GanttSection"; +import OverviewSection from "../../../modules/event/OverviewSection"; +import StaffSection from "../../../modules/event/StaffSection"; -interface PageProps { - params: { - id: string; - }; +export interface EventData { + id: string; + name: string; + description: string; + startDate: string; + endDate: string; + location: string; + type: string; + isPublic: boolean; + createdAt: string; + updatedAt: string; + createdBy?: string; +} + +interface NavigationItem { + id: string; + label: string; + component: React.ComponentType<{ eventData: EventData }>; + badge?: number; } -export default function EventPage({ params }: PageProps) { - const searchParams = useSearchParams(); - const day = Number(searchParams.get("day")) || 1; +const FALLBACK_EVENT: EventData = { + id: "", + name: "Event", + description: "", + startDate: new Date().toISOString().split("T")[0] ?? "", + endDate: new Date(Date.now() + 86400000).toISOString().split("T")[0] ?? "", + location: "TBD", + type: "custom", + isPublic: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), +}; + +export default function EventManagementSPA() { + const params = useParams(); + const router = useRouter(); + const eventId = params.id as string; + + const [currentTime, setCurrentTime] = useState(new Date()); + const [hasMounted, setHasMounted] = useState(false); + const [activeSection, setActiveSection] = useState("overview"); + const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); + const [isUserMenuOpen, setIsUserMenuOpen] = useState(false); + const [isNotifOpen, setIsNotifOpen] = useState(false); + const [isPublic, setIsPublic] = useState(true); + const [savedFeedback, setSavedFeedback] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + + const userMenuRef = useRef(null); + const notifRef = useRef(null); + + const { user } = useSession(); + const supabase = createClient(); + + const { event: backendEvent, isLoading: eventLoading } = useGetEvent(eventId); + + const eventData: EventData = backendEvent + ? { + id: String(backendEvent.id ?? eventId), + name: String(backendEvent.name ?? "Event"), + description: String(backendEvent.description ?? ""), + startDate: + backendEvent.startDate instanceof Date + ? ((backendEvent.startDate as Date).toISOString().split("T")[0] ?? "") + : (String(backendEvent.startDate ?? "").split("T")[0] ?? ""), + endDate: + backendEvent.endDate instanceof Date + ? ((backendEvent.endDate as Date).toISOString().split("T")[0] ?? "") + : (String(backendEvent.endDate ?? "").split("T")[0] ?? ""), + location: String(backendEvent.location ?? "TBD"), + type: "custom", + isPublic, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + createdBy: String(backendEvent.createdBy ?? ""), + } + : { ...FALLBACK_EVENT, id: eventId, isPublic }; + + const { getLatestAPNotation, sessionEnds } = useSessionManager(); + const { data: agendaData } = useGetAgenda(); + + const notifications = sessionEnds + .filter((se) => se.difference > 0) + .sort((a, b) => b.actualEndTime.getTime() - a.actualEndTime.getTime()) + .slice(0, 10) + .map((se) => { + const slot = (agendaData ?? []).find( + (s: { id: string; activity?: string }) => s.id === se.slotId + ); + return { + id: se.slotId, + message: `"${slot?.activity ?? se.slotId}" ended AP+${se.difference}m late`, + time: se.actualEndTime, + }; + }); + + const latestAP = hasMounted ? getLatestAPNotation() : null; + const unreadCount = notifications.length; + + const handleSignOut = async () => { + await supabase.auth.signOut(); + router.push("/auth/login"); + }; + + const handleSave = useCallback(() => { + setSavedFeedback(true); + setTimeout(() => setSavedFeedback(false), 2000); + }, []); + + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (userMenuRef.current && !userMenuRef.current.contains(e.target as Node)) { + setIsUserMenuOpen(false); + } + if (notifRef.current && !notifRef.current.contains(e.target as Node)) { + setIsNotifOpen(false); + } + } + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + useEffect(() => { + setHasMounted(true); + const timer = setInterval(() => setCurrentTime(new Date()), 1000); + return () => clearInterval(timer); + }, []); + + useEffect(() => { + if (backendEvent) setIsPublic(true); + }, [backendEvent]); + + const formatTime = (date: Date) => { + const h = date.getHours().toString().padStart(2, "0"); + const m = date.getMinutes().toString().padStart(2, "0"); + const s = date.getSeconds().toString().padStart(2, "0"); + return `${h}:${m}:${s}`; + }; - const { data, isLoading, error } = useGetAgenda(); + const navigationItems: NavigationItem[] = [ + { id: "overview", label: "Overview", component: OverviewSection }, + { id: "agenda", label: "Agenda (AP)", component: AgendaSection }, + { id: "gantt", label: "Gantt Chart", component: GanttSection }, + { id: "teams", label: "Staff & Participant", component: StaffSection }, + { id: "extra", label: "Extra", component: ExtraSection }, + ]; - if (isLoading) return
Loading agenda...
; - if (error) return
Error: {error.message}
; + const filteredNavItems = searchQuery + ? navigationItems.filter((item) => item.label.toLowerCase().includes(searchQuery.toLowerCase())) + : navigationItems; + + const handleSectionChange = (sectionId: string) => { + setActiveSection(sectionId); + window.history.replaceState({}, "", `?section=${sectionId}`); + }; + + const currentSection = navigationItems.find((item) => item.id === activeSection); + const CurrentComponent = currentSection?.component ?? OverviewSection; return ( -
-

- Agenda for Event {params.id} on Day {day} -

-
{JSON.stringify(data, null, 2)}
+
+ {isMobileMenuOpen && ( +
setIsMobileMenuOpen(false)} + onKeyDown={(e) => e.key === "Escape" && setIsMobileMenuOpen(false)} + role="button" + tabIndex={0} + aria-label="Close mobile menu" + /> + )} + + {/* Left Sidebar */} +
+
+ +
+ + {/* Logo */} +
+
+ +
+
+ + {/* Search */} +
+
+ + setSearchQuery(e.target.value)} + /> +
+
+ + {/* Navigation */} +
+
+
+ {eventLoading ? "Loading..." : eventData.name} +
+
+ {filteredNavItems.map((item) => ( + + ))} +
+ + + + {latestAP && ( +
+ Event running: {latestAP.notation} +
+ )} +
+
+ + {/* Bottom */} +
+
+ +
+ ติดต่อซัพพอร์ต + + +
+ +
+ {user?.avatar_url ? ( + Profile + ) : ( +
+ + {user?.name?.charAt(0)?.toUpperCase() ?? "U"} + +
+ )} +
+
{user?.name ?? "Loading..."}
+
{user?.email ?? "Loading..."}
+
+ +
+ + {isUserMenuOpen && ( +
+ +
+ )} +
+
+
+
+ + {/* Main Content */} +
+ {/* Header */} +
+
+
+ + +
+
+ + / + {currentSection?.label ?? "Overview"} +
+

+ {eventLoading ? "Loading..." : eventData.name} +

+
+ + {hasMounted ? formatTime(currentTime) : "--:--:--"} + {latestAP && ( + + {latestAP.notation} + + )} +
+
+
+ +
+
+ + + {isNotifOpen && ( +
+
+ การแจ้งเตือน + {unreadCount > 0 && ( + {unreadCount} รายการ + )} +
+
+ {notifications.length === 0 ? ( +
+ ไม่มีการแจ้งเตือน +
+ ) : ( + notifications.map((n) => ( +
+

{n.message}

+

+ {n.time.toLocaleTimeString("th-TH")} +

+
+ )) + )} +
+
+ )} +
+ + + + +
+
+
+ + {/* Dynamic Content */} +
+ {CurrentComponent && } +
+
); } diff --git a/apps/web/src/app/event/layout.tsx b/apps/web/src/app/event/layout.tsx index 02ab029..445b608 100644 --- a/apps/web/src/app/event/layout.tsx +++ b/apps/web/src/app/event/layout.tsx @@ -1,17 +1,13 @@ import { redirect } from "next/navigation"; import { getSession } from "@/lib/auth"; -export default async function DashboardLayout({ children }: { children: React.ReactNode }) { +export default async function EventLayout({ children }: { children: React.ReactNode }) { const session = await getSession(); if (!session) { redirect("/auth/login"); - return null; // Ensure the function returns null after redirecting + return null; } - return ( -
-
{children}
-
- ); + return <>{children}; } diff --git a/apps/web/src/app/event/page.tsx b/apps/web/src/app/event/page.tsx index c310e79..f48f1bc 100644 --- a/apps/web/src/app/event/page.tsx +++ b/apps/web/src/app/event/page.tsx @@ -1,373 +1,631 @@ "use client"; -import { Clock, Menu, MoreHorizontal, Search, X } from "lucide-react"; +import { + AlertCircle, + Calendar, + ChevronRight, + Clock, + Loader2, + LogOut, + MapPin, + Plus, + Search, + X, +} from "lucide-react"; import Image from "next/image"; -import { useParams } from "next/navigation"; -import type React from "react"; -import { useEffect, useState } from "react"; -import { Button } from "@/components/atoms/button"; -import { Input } from "@/components/atoms/input"; -import { getSession } from "@/lib/auth"; -import AgendaSection from "../../modules/event/AgendaSection"; -// Import all section components -import OverviewSection from "../../modules/event/OverviewSection"; - -// import GanttSection from "./components/gantt-section"; -// import TeamsSection from "./components/teams-section"; -// import ExtraSection from "./components/extra-section"; - -// Types for backend readiness -interface Event { - id: string; - name: string; - description: string; - startDate: string; - endDate: string; - location: string; - type: string; - isPublic: boolean; - createdAt: string; - updatedAt: string; +import { useRouter } from "next/navigation"; +import { useRef, useState } from "react"; +import { useCreateEvent } from "@/hooks/use-create-event"; +import { type EventItem, useGetEvents } from "@/hooks/use-get-events"; +import { useSession } from "@/hooks/use-session"; +import { createClient } from "@/lib/supabase"; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const CARD_GRADIENTS = [ + ["#7c3aed", "#a78bfa"], + ["#0891b2", "#22d3ee"], + ["#059669", "#34d399"], + ["#d97706", "#fbbf24"], + ["#dc2626", "#f87171"], + ["#db2777", "#f472b6"], + ["#2563eb", "#60a5fa"], + ["#7c3aed", "#ec4899"], +] as const; + +function getGradient(id: string): readonly [string, string] { + const hash = id.split("").reduce((acc, ch) => acc + ch.charCodeAt(0), 0); + return CARD_GRADIENTS[hash % CARD_GRADIENTS.length] ?? CARD_GRADIENTS[0]; } -interface User { - id: string; - name: string; - email: string; - avatar_url?: string; +function formatDateTH(date: string | Date) { + return new Date(date).toLocaleDateString("th-TH", { + day: "numeric", + month: "short", + year: "numeric", + }); } -interface NavigationItem { - id: string; - label: string; - component: React.ComponentType<{ eventData: Event }>; - badge?: number; +function formatDateShortTH(date: string | Date) { + return new Date(date).toLocaleDateString("th-TH", { + day: "numeric", + month: "short", + }); } -// TODO: Change from static page to supabase -// Mock event data - ready for Supabase integration -const getEventData = (eventId: string): Event => { - const eventMap: Record = { - stupidhackathon9: { - id: "stupidhackathon9", - name: "Stupid Hackathon #9", - description: "The most ridiculous hackathon in Thailand", - startDate: "2025-07-26", - endDate: "2025-07-27", - location: "Bangkok, Thailand", - type: "hackathon", - isPublic: true, - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T00:00:00Z", - }, - stupidhackathon8: { - id: "stupidhackathon8", - name: "Stupid Hackathon #8", - description: "Previous edition of the hackathon", - startDate: "2024-06-15", - endDate: "2024-06-17", - location: "Bangkok, Thailand", - type: "hackathon", - isPublic: true, - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T00:00:00Z", - }, - }; - return ( - eventMap[eventId] || { - id: eventId, - name: "Event", - description: "Event description will be added here", - startDate: new Date().toISOString().split("T")[0] ?? "", - endDate: new Date(Date.now() + 86400000).toISOString().split("T")[0] ?? "", - location: "TBD", - type: "custom", - isPublic: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - } - ); -}; +function getDaysUntil(date: string | Date) { + return Math.ceil((new Date(date).getTime() - Date.now()) / (1000 * 60 * 60 * 24)); +} -export default function EventManagementSPA() { - const params = useParams(); - const eventId = params.eventId as string; - const [currentTime, setCurrentTime] = useState(new Date()); - const [hasMounted, setHasMounted] = useState(false); - const [activeSection, setActiveSection] = useState("overview"); - const [eventData] = useState(() => - //setEventData - getEventData(eventId) - ); - const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); - const [user, setUser] = useState(null); - - // Navigation items - all ready for backend - const navigationItems: NavigationItem[] = [ - { id: "overview", label: "Overview", component: OverviewSection }, - { id: "agenda", label: "Agenda (AP)", component: AgendaSection }, - // { id: "gantt", label: "Gantt Chart", component: GanttSection }, - // { id: "teams", label: "Staff & Participant", component: TeamsSection }, - // { id: "extra", label: "Extra", component: ExtraSection }, - ]; - - useEffect(() => { - setHasMounted(true); - const timer = setInterval(() => { - setCurrentTime(new Date()); - }, 1000); - - // Fetch user session - const fetchUser = async () => { - try { - const sessionUser = await getSession(); - setUser(sessionUser); - } catch (error) { - console.error("Failed to fetch user session:", error); - } - }; +// ─── Create Event Modal ─────────────────────────────────────────────────────── - fetchUser(); +interface CreateModalProps { + onClose: () => void; +} - return () => clearInterval(timer); - }, []); +function CreateEventModal({ onClose }: CreateModalProps) { + const router = useRouter(); + const { createEventAsync, isPending } = useCreateEvent(); + const firstInputRef = useRef(null); - const formatTime = (date: Date) => { - const hours = date.getHours().toString().padStart(2, "0"); - const minutes = date.getMinutes().toString().padStart(2, "0"); - const seconds = date.getSeconds().toString().padStart(2, "0"); - return `${hours}:${minutes}:${seconds}`; - }; + const [form, setForm] = useState({ + name: "", + startDate: "", + endDate: "", + location: "", + description: "", + }); + const [error, setError] = useState(null); - const handleSectionChange = (sectionId: string) => { - setActiveSection(sectionId); - // Ready for URL state management and analytics tracking - // window.history.pushState({}, '', `/${eventId}/${sectionId}`) - }; + const set = + (field: keyof typeof form) => (e: React.ChangeEvent) => + setForm((prev) => ({ ...prev, [field]: e.target.value })); - // Get current section component - const currentSection = navigationItems.find((item) => item.id === activeSection); - const CurrentComponent = currentSection?.component || OverviewSection; + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); - return ( -
- {/* Mobile Menu Overlay */} - {isMobileMenuOpen && ( -
setIsMobileMenuOpen(false)} - onKeyDown={(e) => { - if (e.key === "Escape") { - setIsMobileMenuOpen(false); - } - }} - role="button" - tabIndex={0} - aria-label="Close mobile menu" - /> - )} + if (!form.name.trim()) return setError("กรุณากรอกชื่ออีเวนต์"); + if (!form.startDate) return setError("กรุณาเลือกวันเริ่มต้น"); + if (!form.endDate) return setError("กรุณาเลือกวันสิ้นสุด"); + if (new Date(form.endDate) < new Date(form.startDate)) return setError("วันสิ้นสุดต้องอยู่หลังวันเริ่มต้น"); + if (!form.location.trim()) return setError("กรุณากรอกสถานที่"); + + try { + const result = await createEventAsync({ + name: form.name.trim(), + startDate: new Date(form.startDate).toISOString(), + endDate: new Date(form.endDate).toISOString(), + location: form.location.trim(), + description: form.description.trim() || null, + }); + const newId = (result as { data?: { id?: string } })?.data?.id; + if (newId) { + router.push(`/event/${newId}`); + } else { + onClose(); + } + } catch { + setError("เกิดข้อผิดพลาด กรุณาลองใหม่อีกครั้ง"); + } + }; - {/* Left Sidebar - Now responsive */} + return ( +
- {/* Mobile close button */} -
+ {/* Header */} +
+
+

สร้างอีเวนต์ใหม่

+

กรอกข้อมูลเพื่อเริ่มจัดงาน

+
- {/* Logo */} -
-
-
- {"E"} -
- Eventer + {/* Form */} +
+ {/* Name */} +
+ +
-
- {/* Search */} -
-
- - -
-
- - {/* Navigation */} -
-
-
- {eventData.name} -
-
- {navigationItems.map((item) => ( - - ))} + {/* Dates row */} +
+
+ +
-
- การแจ้งเตือน - - 0 - +
+ +
-
- {/* Bottom Section - Simplified for mobile */} -
-
-
-
- ติดต่อซัพพอร์ต -
-
-
- ตั้งค่าการใช้งาน -
+ {/* Location */} +
+ +
-
-
Used space
-
- Your team has used 80% of your available space. Need more? -
-
- - -
+ {/* Description */} +
+ +