diff --git a/Dockerfile b/Dockerfile index 60ecd71..23c83c4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,5 +14,5 @@ COPY . . ENV NODE_ENV=production EXPOSE 3000 -USER bun -CMD ["bun", "run", "src/index.ts"] +RUN chmod +x entrypoint.sh +CMD ["./entrypoint.sh"] diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..fb89576 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,10 @@ +#!/bin/bash +set -e + +# Run seed (creates test user if not exists) +echo "Running database seed..." +bun run db:seed || echo "Seed skipped or failed (may already exist)" + +# Start the app +echo "Starting API..." +exec bun run src/index.ts diff --git a/package.json b/package.json index ed9eccc..c95b358 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:push": "drizzle-kit push", - "db:studio": "drizzle-kit studio" + "db:studio": "drizzle-kit studio", + "db:seed": "bun run src/db/seed.ts" }, "devDependencies": { "@types/bun": "latest", diff --git a/src/db/seed.ts b/src/db/seed.ts new file mode 100644 index 0000000..aacd633 --- /dev/null +++ b/src/db/seed.ts @@ -0,0 +1,59 @@ +import { db } from './index'; +import { users, accounts } from './schema'; +import { eq } from 'drizzle-orm'; + +// Hash password using the same method as BetterAuth (bcrypt via Bun) +async function hashPassword(password: string): Promise { + return await Bun.password.hash(password, { + algorithm: 'bcrypt', + cost: 10, + }); +} + +async function seed() { + const testEmail = 'test@test.com'; + + // Check if test user already exists + const [existing] = await db.select() + .from(users) + .where(eq(users.email, testEmail)) + .limit(1); + + if (existing) { + console.log('✓ Test user already exists'); + return; + } + + // Create test user + const userId = crypto.randomUUID(); + const hashedPassword = await hashPassword('test'); + + await db.insert(users).values({ + id: userId, + email: testEmail, + name: 'Test User', + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + }); + + // Create credential account (for email/password login) + await db.insert(accounts).values({ + id: crypto.randomUUID(), + userId: userId, + accountId: userId, + providerId: 'credential', + password: hashedPassword, + createdAt: new Date(), + updatedAt: new Date(), + }); + + console.log('✓ Created test user: test@test.com / test'); +} + +seed() + .then(() => process.exit(0)) + .catch((err) => { + console.error('Seed failed:', err); + process.exit(1); + }); diff --git a/src/index.ts b/src/index.ts index e111011..27ea14f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ const app = new Elysia() .use(cors({ origin: process.env.ALLOWED_ORIGINS?.split(',') || ['http://localhost:3000'], credentials: true, + exposeHeaders: ['set-auth-token'], // Expose bearer token header for mobile apps })) // Health check diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 5f1deb1..bfe0347 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,4 +1,5 @@ import { betterAuth } from 'better-auth'; +import { bearer } from 'better-auth/plugins'; import { drizzleAdapter } from 'better-auth/adapters/drizzle'; import { db } from '../db'; import * as schema from '../db/schema'; @@ -13,6 +14,9 @@ export const auth = betterAuth({ verification: schema.verifications, }, }), + plugins: [ + bearer(), // Enable bearer token auth for mobile apps + ], emailAndPassword: { enabled: true, requireEmailVerification: false, // Enable later for production diff --git a/src/routes/clients.ts b/src/routes/clients.ts index 158d583..f39daa3 100644 --- a/src/routes/clients.ts +++ b/src/routes/clients.ts @@ -1,9 +1,61 @@ import { Elysia, t } from 'elysia'; import { db } from '../db'; -import { clients } from '../db/schema'; +import { clients, events } from '../db/schema'; import { eq, and, ilike, or, sql } from 'drizzle-orm'; import type { User } from '../lib/auth'; +// Helper to sync birthday/anniversary events for a client +async function syncClientEvents(userId: string, client: { id: string; firstName: string; birthday: Date | null; anniversary: Date | null }) { + // Sync birthday event + if (client.birthday) { + const [existing] = await db.select() + .from(events) + .where(and(eq(events.clientId, client.id), eq(events.type, 'birthday'))) + .limit(1); + + if (!existing) { + await db.insert(events).values({ + userId, + clientId: client.id, + type: 'birthday', + title: `${client.firstName}'s Birthday`, + date: client.birthday, + recurring: true, + reminderDays: 7, + }); + } else { + // Update date if changed + await db.update(events) + .set({ date: client.birthday, title: `${client.firstName}'s Birthday` }) + .where(eq(events.id, existing.id)); + } + } + + // Sync anniversary event + if (client.anniversary) { + const [existing] = await db.select() + .from(events) + .where(and(eq(events.clientId, client.id), eq(events.type, 'anniversary'))) + .limit(1); + + if (!existing) { + await db.insert(events).values({ + userId, + clientId: client.id, + type: 'anniversary', + title: `${client.firstName}'s Anniversary`, + date: client.anniversary, + recurring: true, + reminderDays: 7, + }); + } else { + await db.update(events) + .set({ date: client.anniversary, title: `${client.firstName}'s Anniversary` }) + .where(eq(events.id, existing.id)); + } + } +} + // Validation schemas const clientSchema = t.Object({ firstName: t.String({ minLength: 1 }), @@ -108,6 +160,9 @@ export const clientRoutes = new Elysia({ prefix: '/clients' }) }) .returning(); + // Auto-sync birthday/anniversary events + await syncClientEvents(user.id, client); + return client; }, { body: clientSchema, @@ -147,6 +202,11 @@ export const clientRoutes = new Elysia({ prefix: '/clients' }) throw new Error('Client not found'); } + // Auto-sync birthday/anniversary events if dates changed + if (body.birthday !== undefined || body.anniversary !== undefined || body.firstName !== undefined) { + await syncClientEvents(user.id, client); + } + return client; }, { params: t.Object({ diff --git a/src/routes/events.ts b/src/routes/events.ts index adb0a3d..ffa51f5 100644 --- a/src/routes/events.ts +++ b/src/routes/events.ts @@ -206,6 +206,73 @@ export const eventRoutes = new Elysia({ prefix: '/events' }) }), }) + // Sync ALL events from all clients' birthdays/anniversaries + .post('/sync-all', async ({ user }: { user: User }) => { + // Get all clients for this user + const userClients = await db.select() + .from(clients) + .where(eq(clients.userId, user.id)); + + let created = 0; + let updated = 0; + + for (const client of userClients) { + // Sync birthday + if (client.birthday) { + const [existing] = await db.select() + .from(events) + .where(and(eq(events.clientId, client.id), eq(events.type, 'birthday'))) + .limit(1); + + if (!existing) { + await db.insert(events).values({ + userId: user.id, + clientId: client.id, + type: 'birthday', + title: `${client.firstName}'s Birthday`, + date: client.birthday, + recurring: true, + reminderDays: 7, + }); + created++; + } else { + await db.update(events) + .set({ date: client.birthday, title: `${client.firstName}'s Birthday` }) + .where(eq(events.id, existing.id)); + updated++; + } + } + + // Sync anniversary + if (client.anniversary) { + const [existing] = await db.select() + .from(events) + .where(and(eq(events.clientId, client.id), eq(events.type, 'anniversary'))) + .limit(1); + + if (!existing) { + await db.insert(events).values({ + userId: user.id, + clientId: client.id, + type: 'anniversary', + title: `${client.firstName}'s Anniversary`, + date: client.anniversary, + recurring: true, + reminderDays: 7, + }); + created++; + } else { + await db.update(events) + .set({ date: client.anniversary, title: `${client.firstName}'s Anniversary` }) + .where(eq(events.id, existing.id)); + updated++; + } + } + } + + return { success: true, created, updated, clientsProcessed: userClients.length }; + }) + // Sync events from client birthdays/anniversaries .post('/sync/:clientId', async ({ params, user }: { params: { clientId: string }; user: User }) => { // Get client