diff --git a/apps/api/.env.example b/apps/api/.env.example index 4860da3..951c986 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -1,6 +1,21 @@ # PostgreSQL connection string DATABASE_URL="postgresql://postgres:postgres@localhost:5432/gruperly?schema=public" +# Frontend origin (CORS + CSRF) +WEB_URL="http://localhost:6173" + # Better Auth BETTER_AUTH_SECRET="replace-with-a-strong-secret" BETTER_AUTH_URL="http://localhost:4000" + +# Social providers +GOOGLE_CLIENT_ID="" +GOOGLE_CLIENT_SECRET="" + +# Email (SMTP por ahora; migrar a un proveedor transaccional en el futuro) +EMAIL_PROVIDER="smtp" +EMAIL_SERVER_HOST="" +EMAIL_SERVER_PORT="587" +EMAIL_SERVER_USER="" +EMAIL_SERVER_PASSWORD="" +EMAIL_FROM="Gruperly " \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json index 7bdf9bf..2863d1c 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -14,15 +14,18 @@ "db:studio": "prisma studio" }, "dependencies": { + "@better-auth/passkey": "^1.7.2", "@gruperly/shared": "workspace:*", "@hono/zod-validator": "^0.8.0", "@prisma/adapter-pg": "^7.10.0", "@prisma/client": "^7.10.0", - "better-auth": "^1.1.0", - "hono": "^4.6.0" + "better-auth": "1.7.2", + "hono": "^4.6.0", + "nodemailer": "^9.0.6" }, "devDependencies": { "@types/bun": "^1.0.0", + "@types/nodemailer": "^8.0.1", "prisma": "^7.10.0", "typescript": "^5.7.0" } diff --git a/apps/api/prisma/migrations/20260904220856_init_db/migration.sql b/apps/api/prisma/migrations/20260904220856_init_db/migration.sql new file mode 100644 index 0000000..c196eed --- /dev/null +++ b/apps/api/prisma/migrations/20260904220856_init_db/migration.sql @@ -0,0 +1,191 @@ +-- CreateEnum +CREATE TYPE "Role" AS ENUM ('OWNER', 'ADMIN', 'MEMBER'); + +-- CreateEnum +CREATE TYPE "PaymentStatus" AS ENUM ('PENDING', 'PAID', 'OVERDUE', 'CANCELLED'); + +-- CreateEnum +CREATE TYPE "WaitlistStatus" AS ENUM ('PENDING', 'INVITED', 'JOINED', 'DECLINED'); + +-- CreateTable +CREATE TABLE "users" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "email" TEXT NOT NULL, + "emailVerified" BOOLEAN NOT NULL DEFAULT false, + "image" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "users_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "accounts" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "providerId" TEXT NOT NULL, + "providerAccountId" TEXT NOT NULL, + "refreshToken" TEXT, + "accessToken" TEXT, + "accessTokenExpiresAt" TIMESTAMP(3), + "refreshTokenExpiresAt" TIMESTAMP(3), + "scope" TEXT, + "idToken" TEXT, + "password" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "accounts_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "sessions" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "token" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "ipAddress" TEXT, + "userAgent" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "sessions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "verifications" ( + "id" TEXT NOT NULL, + "identifier" TEXT NOT NULL, + "value" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "verifications_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "groups" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "createdById" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "groups_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "members" ( + "id" TEXT NOT NULL, + "groupId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "role" "Role" NOT NULL DEFAULT 'MEMBER', + "joinedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "members_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "students" ( + "id" TEXT NOT NULL, + "groupId" TEXT NOT NULL, + "fullName" TEXT NOT NULL, + "email" TEXT, + "phone" TEXT, + "guardianName" TEXT, + "guardianPhone" TEXT, + "notes" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "students_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "payments" ( + "id" TEXT NOT NULL, + "groupId" TEXT NOT NULL, + "studentId" TEXT NOT NULL, + "amount" DECIMAL(10,2) NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'MXN', + "status" "PaymentStatus" NOT NULL DEFAULT 'PENDING', + "dueDate" TIMESTAMP(3) NOT NULL, + "paidAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "payments_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "waitlist_entries" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "name" TEXT, + "status" "WaitlistStatus" NOT NULL DEFAULT 'PENDING', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "userId" TEXT, + + CONSTRAINT "waitlist_entries_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "accounts_providerId_providerAccountId_key" ON "accounts"("providerId", "providerAccountId"); + +-- CreateIndex +CREATE UNIQUE INDEX "sessions_token_key" ON "sessions"("token"); + +-- CreateIndex +CREATE UNIQUE INDEX "verifications_identifier_value_key" ON "verifications"("identifier", "value"); + +-- CreateIndex +CREATE UNIQUE INDEX "members_groupId_userId_key" ON "members"("groupId", "userId"); + +-- CreateIndex +CREATE INDEX "students_groupId_idx" ON "students"("groupId"); + +-- CreateIndex +CREATE INDEX "payments_groupId_idx" ON "payments"("groupId"); + +-- CreateIndex +CREATE INDEX "payments_studentId_idx" ON "payments"("studentId"); + +-- CreateIndex +CREATE UNIQUE INDEX "waitlist_entries_userId_key" ON "waitlist_entries"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "waitlist_entries_email_key" ON "waitlist_entries"("email"); + +-- AddForeignKey +ALTER TABLE "accounts" ADD CONSTRAINT "accounts_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "groups" ADD CONSTRAINT "groups_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "members" ADD CONSTRAINT "members_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "groups"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "members" ADD CONSTRAINT "members_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "students" ADD CONSTRAINT "students_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "groups"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "payments" ADD CONSTRAINT "payments_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "groups"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "payments" ADD CONSTRAINT "payments_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "students"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "waitlist_entries" ADD CONSTRAINT "waitlist_entries_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/20260904222817_add_auth_tables/migration.sql b/apps/api/prisma/migrations/20260904222817_add_auth_tables/migration.sql new file mode 100644 index 0000000..eb96796 --- /dev/null +++ b/apps/api/prisma/migrations/20260904222817_add_auth_tables/migration.sql @@ -0,0 +1,117 @@ +/* + Warnings: + + - You are about to drop the column `groupId` on the `members` table. All the data in the column will be lost. + - You are about to drop the column `joinedAt` on the `members` table. All the data in the column will be lost. + - The `role` column on the `members` table would be dropped and recreated. This will lead to data loss if there is data in the column. + - A unique constraint covering the columns `[organizationId,userId]` on the table `members` will be added. If there are existing duplicate values, this will fail. + - Added the required column `organizationId` to the `members` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropForeignKey +ALTER TABLE "members" DROP CONSTRAINT "members_groupId_fkey"; + +-- DropIndex +DROP INDEX "members_groupId_userId_key"; + +-- AlterTable +ALTER TABLE "members" DROP COLUMN "groupId", +DROP COLUMN "joinedAt", +ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "organizationId" TEXT NOT NULL, +DROP COLUMN "role", +ADD COLUMN "role" TEXT NOT NULL DEFAULT 'member'; + +-- AlterTable +ALTER TABLE "sessions" ADD COLUMN "activeOrganizationId" TEXT; + +-- CreateTable +CREATE TABLE "organizations" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "logo" TEXT, + "metadata" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "organizations_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "invitations" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "email" TEXT NOT NULL, + "role" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'pending', + "inviterId" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "invitations_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "passkeys" ( + "id" TEXT NOT NULL, + "name" TEXT, + "publicKey" TEXT NOT NULL, + "credentialID" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "counter" INTEGER NOT NULL, + "deviceType" TEXT NOT NULL, + "backedUp" BOOLEAN NOT NULL, + "transports" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "aaguid" TEXT, + + CONSTRAINT "passkeys_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "group_members" ( + "id" TEXT NOT NULL, + "groupId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "role" "Role" NOT NULL DEFAULT 'MEMBER', + "joinedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "group_members_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "organizations_slug_key" ON "organizations"("slug"); + +-- CreateIndex +CREATE INDEX "invitations_organizationId_idx" ON "invitations"("organizationId"); + +-- CreateIndex +CREATE UNIQUE INDEX "passkeys_credentialID_key" ON "passkeys"("credentialID"); + +-- CreateIndex +CREATE INDEX "passkeys_userId_idx" ON "passkeys"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "group_members_groupId_userId_key" ON "group_members"("groupId", "userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "members_organizationId_userId_key" ON "members"("organizationId", "userId"); + +-- AddForeignKey +ALTER TABLE "members" ADD CONSTRAINT "members_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "invitations" ADD CONSTRAINT "invitations_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "invitations" ADD CONSTRAINT "invitations_inviterId_fkey" FOREIGN KEY ("inviterId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "passkeys" ADD CONSTRAINT "passkeys_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "group_members" ADD CONSTRAINT "group_members_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "groups"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "group_members" ADD CONSTRAINT "group_members_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/20260905203538_update_account_model/migration.sql b/apps/api/prisma/migrations/20260905203538_update_account_model/migration.sql new file mode 100644 index 0000000..41894ac --- /dev/null +++ b/apps/api/prisma/migrations/20260905203538_update_account_model/migration.sql @@ -0,0 +1,22 @@ +/* + Warnings: + + - You are about to drop the column `providerAccountId` on the `accounts` table. All the data in the column will be lost. + - A unique constraint covering the columns `[issuer,accountId]` on the table `accounts` will be added. If there are existing duplicate values, this will fail. + - Added the required column `accountId` to the `accounts` table without a default value. This is not possible if the table is not empty. + - Added the required column `issuer` to the `accounts` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropIndex +DROP INDEX "accounts_providerId_providerAccountId_key"; + +-- AlterTable +ALTER TABLE "accounts" DROP COLUMN "providerAccountId", +ADD COLUMN "accountId" TEXT NOT NULL, +ADD COLUMN "issuer" TEXT NOT NULL; + +-- CreateIndex +CREATE INDEX "accounts_userId_idx" ON "accounts"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "accounts_issuer_accountId_key" ON "accounts"("issuer", "accountId"); diff --git a/apps/api/prisma/migrations/migration_lock.toml b/apps/api/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/apps/api/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 58431bb..8cf20d6 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -19,45 +19,51 @@ model User { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - accounts Account[] - sessions Session[] - groupsMember Member[] - groupsOwner Group[] @relation("OwnerGroups") - waitlist WaitlistEntry[] + accounts Account[] + sessions Session[] + passkeys Passkey[] + groupsMember GroupMember[] + groupsOwner Group[] @relation("OwnerGroups") + orgMemberships Member[] + orgInvitations Invitation[] @relation("InvitedBy") + waitlist WaitlistEntry[] @@map("users") } model Account { - id String @id @default(cuid()) - userId String - providerId String - providerAccountId String - refreshToken String? - accessToken String? + id String @id @default(cuid()) + userId String + providerId String + accountId String + issuer String + accessToken String? + refreshToken String? + idToken String? accessTokenExpiresAt DateTime? refreshTokenExpiresAt DateTime? - scope String? - idToken String? - password String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + scope String? + password String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade) - @@unique([providerId, providerAccountId]) + @@unique([issuer, accountId]) + @@index([userId]) @@map("accounts") } model Session { - id String @id @default(cuid()) - userId String - token String @unique - expiresAt DateTime - ipAddress String? - userAgent String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + userId String + token String @unique + expiresAt DateTime + ipAddress String? + userAgent String? + activeOrganizationId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -76,6 +82,73 @@ model Verification { @@map("verifications") } +// Better Auth - Organization plugin +model Organization { + id String @id @default(cuid()) + name String + slug String @unique + logo String? + metadata Json? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + members Member[] + invitations Invitation[] + + @@map("organizations") +} + +model Member { + id String @id @default(cuid()) + organizationId String + userId String + role String @default("member") + createdAt DateTime @default(now()) + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([organizationId, userId]) + @@map("members") +} + +model Invitation { + id String @id @default(cuid()) + organizationId String + email String + role String + status String @default("pending") + inviterId String + expiresAt DateTime + createdAt DateTime @default(now()) + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + inviter User @relation("InvitedBy", fields: [inviterId], references: [id], onDelete: Cascade) + + @@index([organizationId]) + @@map("invitations") +} + +// Better Auth - Passkey plugin +model Passkey { + id String @id @default(cuid()) + name String? + publicKey String + credentialID String @unique + userId String + counter Int + deviceType String + backedUp Boolean + transports String? + createdAt DateTime @default(now()) + aaguid String? + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@map("passkeys") +} + // Domain models model Group { id String @id @default(cuid()) @@ -85,26 +158,26 @@ model Group { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - owner User @relation("OwnerGroups", fields: [createdById], references: [id], onDelete: Cascade) - members Member[] + owner User @relation("OwnerGroups", fields: [createdById], references: [id], onDelete: Cascade) + members GroupMember[] students Student[] payments Payment[] @@map("groups") } -model Member { - id String @id @default(cuid()) - groupId String - userId String - role Role @default(MEMBER) // OWNER, ADMIN, MEMBER +model GroupMember { + id String @id @default(cuid()) + groupId String + userId String + role Role @default(MEMBER) // OWNER, ADMIN, MEMBER joinedAt DateTime @default(now()) group Group @relation(fields: [groupId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([groupId, userId]) - @@map("members") + @@map("group_members") } enum Role { diff --git a/apps/api/src/auth.ts b/apps/api/src/auth.ts new file mode 100644 index 0000000..ba683e6 --- /dev/null +++ b/apps/api/src/auth.ts @@ -0,0 +1,63 @@ +import { betterAuth } from 'better-auth' +import { prismaAdapter } from 'better-auth/adapters/prisma' +import { passkey } from '@better-auth/passkey' +import { organization } from 'better-auth/plugins/organization' +import { prisma } from './db' +import { emailProvider } from './lib/email' + +export const auth = betterAuth({ + appName: 'Gruperly', + database: prismaAdapter(prisma, { + provider: 'postgresql', + }), + emailAndPassword: { + enabled: true, + minPasswordLength: 8, + sendResetPassword: async ({ user, url }) => { + await emailProvider.sendPasswordResetEmail({ + email: user.email, + url, + name: user.name, + }) + }, + }, + emailVerification: { + sendOnSignUp: true, + sendOnSignIn: true, + autoSignInAfterVerification: true, + sendVerificationEmail: async ({ user, token }) => { + const webUrl = process.env.WEB_URL ?? 'http://localhost:6173' + const verificationUrl = new URL('/verify-email', webUrl) + verificationUrl.searchParams.set('token', token) + await emailProvider.sendVerificationEmail({ + email: user.email, + url: verificationUrl.toString(), + name: user.name, + }) + }, + }, + socialProviders: { + ...(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET + ? { + google: { + clientId: process.env.GOOGLE_CLIENT_ID, + clientSecret: process.env.GOOGLE_CLIENT_SECRET, + }, + } + : {}), + }, + plugins: [ + organization({ + acronym: 'GRP', + allowUserToCreateOrganization: true, + organizationLimit: 10, + }), + passkey({ + rpName: 'Gruperly', + }), + ], + trustedOrigins: [ + process.env.BETTER_AUTH_URL ?? 'http://localhost:4000', + process.env.WEB_URL ?? 'http://localhost:6173', + ], +}) \ No newline at end of file diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts new file mode 100644 index 0000000..5c27200 --- /dev/null +++ b/apps/api/src/env.ts @@ -0,0 +1,10 @@ +import type { auth } from './auth' + +type Session = typeof auth.$Infer.Session + +export type AppEnv = { + Variables: { + user: Session['user'] | null + session: Session['session'] | null + } +} \ No newline at end of file diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index e6a09c5..c78132e 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,22 +1,50 @@ import { Hono } from 'hono' import { cors } from 'hono/cors' -import { authRoutes } from './routes/auth' -import { groupsRoutes } from './routes/groups' -import { studentsRoutes } from './routes/students' -import { paymentsRoutes } from './routes/payments' -import { waitlistRoutes } from './routes/waitlist' +import { auth } from './auth' +import type { AppEnv } from './env' +import { listGroups, createGroupFromOrganization } from './routes/groups' +import { listPlaceholder as students } from './routes/students' +import { listPlaceholder as payments } from './routes/payments' +import { listPlaceholder as waitlist } from './routes/waitlist' -const app = new Hono() +const app = new Hono() -app.use('*', cors()) +const webOrigin = process.env.WEB_URL ?? 'http://localhost:6173' + +app.use( + '*', + cors({ + origin: [webOrigin], + credentials: true, + allowHeaders: ['Content-Type', 'Authorization'], + maxAge: 600, + }), +) + +// BetterAuth endpoints (sign-up, sign-in, session, org, passkeys, ...) +app.all('/api/auth', (c) => auth.handler(c.req.raw)) +app.all('/api/auth/*', (c) => auth.handler(c.req.raw)) + +// Sesión disponible en todos los handlers vía c.get('user') / c.get('session') +app.use('*', async (c, next) => { + const session = await auth.api.getSession({ headers: c.req.raw.headers }) + if (session) { + c.set('user', session.user) + c.set('session', session.session) + } else { + c.set('user', null) + c.set('session', null) + } + await next() +}) app.get('/', (c) => c.json({ name: 'Gruperly API', status: 'ok' })) -app.route('/auth', authRoutes) -app.route('/groups', groupsRoutes) -app.route('/students', studentsRoutes) -app.route('/payments', paymentsRoutes) -app.route('/waitlist', waitlistRoutes) +app.get('/api/groups', listGroups) +app.post('/api/groups/from-organization', createGroupFromOrganization) +app.get('/api/students', students) +app.get('/api/payments', payments) +app.get('/api/waitlist', waitlist) export default app @@ -27,4 +55,4 @@ Bun.serve({ fetch: app.fetch, }) -console.log(`Gruperly API running on http://localhost:${port}`) +console.log(`Gruperly API running on http://localhost:${port}`) \ No newline at end of file diff --git a/apps/api/src/lib/email.ts b/apps/api/src/lib/email.ts new file mode 100644 index 0000000..e2269e9 --- /dev/null +++ b/apps/api/src/lib/email.ts @@ -0,0 +1,94 @@ +import nodemailer, { type Transporter } from 'nodemailer' + +type VerificationEmailData = { email: string; url: string; name?: string } +type PasswordResetEmailData = { email: string; url: string; name?: string } +type InvitationEmailData = { email: string; url: string; organizationName: string } + +interface EmailProvider { + sendVerificationEmail(data: VerificationEmailData): Promise + sendPasswordResetEmail(data: PasswordResetEmailData): Promise + sendOrganizationInvitation(data: InvitationEmailData): Promise +} + +class SMTPEmailProvider implements EmailProvider { + private transporter: Transporter + + constructor() { + this.transporter = nodemailer.createTransport({ + host: process.env.EMAIL_SERVER_HOST, + port: Number(process.env.EMAIL_SERVER_PORT ?? 587), + secure: (process.env.EMAIL_SERVER_PORT ?? '587') === '465', + auth: { + user: process.env.EMAIL_SERVER_USER, + pass: process.env.EMAIL_SERVER_PASSWORD, + }, + }) + } + + private from() { + return process.env.EMAIL_FROM ?? 'Gruperly ' + } + + private layout(title: string, body: string) { + return ` +
+

Gruperly

+

${title}

+

${body}

+
+ ` + } + + private async send(to: string, subject: string, html: string) { + // Sin SMTP configurado (dev) no rompe el flujo: solo registra un aviso + if (!process.env.EMAIL_SERVER_HOST) { + console.warn(`[email] SMTP no configurado. Email no enviado a ${to}: "${subject}"`) + return + } + await this.transporter.sendMail({ from: this.from(), to, subject, html }) + } + + async sendVerificationEmail({ email, url, name }: VerificationEmailData) { + const link = `Verificar email` + await this.send( + email, + 'Verifica tu email en Gruperly', + this.layout( + 'Confirma tu dirección de email', + `${name ? `Hola ${name}, ` : 'Hola, '}gracias por crear tu cuenta en Gruperly. Para empezar, verifica tu email con el botón de abajo (el enlace vence en 1 hora).

${link}`, + ), + ) + } + + async sendPasswordResetEmail({ email, url, name }: PasswordResetEmailData) { + const link = `Restablecer contraseña` + await this.send( + email, + 'Restablece tu contraseña en Gruperly', + this.layout( + 'Solicitaste restablecer tu contraseña', + `${name ? `Hola ${name}, ` : 'Hola, '}haz clic en el botón para elegir una nueva contraseña (el enlace vence en 1 hora). Si no fuiste tú, ignora este email.

${link}`, + ), + ) + } + + async sendOrganizationInvitation({ email, url, organizationName }: InvitationEmailData) { + const link = `Unirme a ${organizationName}` + await this.send( + email, + `Te invitaron a ${organizationName} en Gruperly`, + this.layout( + `Te invitaron a ${organizationName}`, + `Acepta la invitación con el botón de abajo para empezar a colaborar en Gruperly.

${link}`, + ), + ) + } +} + +// Futuro: proveedores transaccionales (Resend, SendGrid, Postmark). +// Añadir implementaciones y alternar con EMAIL_PROVIDER. +export function createEmailProvider(): EmailProvider { + return new SMTPEmailProvider() +} + +export const emailProvider = createEmailProvider() \ No newline at end of file diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts deleted file mode 100644 index f93a954..0000000 --- a/apps/api/src/routes/auth.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { Hono } from 'hono' - -export const authRoutes = new Hono() - -authRoutes.get('/session', (c) => c.json({ message: 'placeholder' })) diff --git a/apps/api/src/routes/groups.ts b/apps/api/src/routes/groups.ts index 5d0bbe3..46cfff9 100644 --- a/apps/api/src/routes/groups.ts +++ b/apps/api/src/routes/groups.ts @@ -1,5 +1,65 @@ -import { Hono } from 'hono' +import type { Context } from 'hono' +import { z } from 'zod' +import { prisma } from '../db/client' +import type { AppEnv } from '../env' +import { Role } from '../../prisma/generated/prisma/client' -export const groupsRoutes = new Hono() +const fromOrganizationSchema = z.object({ + organizationId: z.string().min(1), +}) -groupsRoutes.get('/', (c) => c.json([])) +export async function listGroups(c: Context) { + const user = c.get('user') + if (!user) return c.json({ message: 'No autorizado' }, 401) + + void user + return c.json([]) +} + +// Crea un Group a partir de una Organization de Better Auth (mapeo 1:1). +// Solo el owner de la organización puede crear su grupo. +export async function createGroupFromOrganization(c: Context) { + const user = c.get('user') + if (!user) return c.json({ message: 'No autorizado' }, 401) + + const body = await c.req.json().catch(() => null) + const parsed = fromOrganizationSchema.safeParse(body) + if (!parsed.success) { + return c.json({ message: 'Cuerpo inválido', issues: parsed.error.issues }, 400) + } + + const organization = await prisma.organization.findUnique({ + where: { id: parsed.data.organizationId }, + include: { members: true }, + }) + if (!organization) return c.json({ message: 'Organización no encontrada' }, 404) + + const membership = organization.members.find((m) => m.userId === user.id) + if (!membership || membership.role !== 'owner') { + return c.json({ message: 'Solo el owner puede crear el grupo' }, 403) + } + + const existing = await prisma.group.findFirst({ + where: { createdById: user.id, name: organization.name }, + }) + if (existing) return c.json({ group: existing, alreadyExists: true }) + + const group = await prisma.$transaction(async (tx) => { + const created = await tx.group.create({ + data: { + name: organization.name, + createdById: user.id, + }, + }) + await tx.groupMember.create({ + data: { + groupId: created.id, + userId: user.id, + role: Role.OWNER, + }, + }) + return created + }) + + return c.json({ group }, 201) +} \ No newline at end of file diff --git a/apps/api/src/routes/payments.ts b/apps/api/src/routes/payments.ts index 167e140..d152b7e 100644 --- a/apps/api/src/routes/payments.ts +++ b/apps/api/src/routes/payments.ts @@ -1,5 +1,5 @@ -import { Hono } from 'hono' +import type { Context } from 'hono' -export const paymentsRoutes = new Hono() - -paymentsRoutes.get('/', (c) => c.json([])) +export function listPlaceholder(c: Context) { + return c.json([]) +} \ No newline at end of file diff --git a/apps/api/src/routes/students.ts b/apps/api/src/routes/students.ts index d14d4dd..d152b7e 100644 --- a/apps/api/src/routes/students.ts +++ b/apps/api/src/routes/students.ts @@ -1,5 +1,5 @@ -import { Hono } from 'hono' +import type { Context } from 'hono' -export const studentsRoutes = new Hono() - -studentsRoutes.get('/', (c) => c.json([])) +export function listPlaceholder(c: Context) { + return c.json([]) +} \ No newline at end of file diff --git a/apps/api/src/routes/waitlist.ts b/apps/api/src/routes/waitlist.ts index 9ad7240..d152b7e 100644 --- a/apps/api/src/routes/waitlist.ts +++ b/apps/api/src/routes/waitlist.ts @@ -1,5 +1,5 @@ -import { Hono } from 'hono' +import type { Context } from 'hono' -export const waitlistRoutes = new Hono() - -waitlistRoutes.get('/', (c) => c.json([])) +export function listPlaceholder(c: Context) { + return c.json([]) +} \ No newline at end of file diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index b5c7b38..1f164f7 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -3,6 +3,7 @@ "compilerOptions": { "moduleResolution": "bundler", "types": ["bun"], + "declaration": false, "paths": { "@gruperly/shared": ["../../packages/shared/src/index.ts"] } diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 0000000..119819a --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1 @@ +VITE_API_URL=http://localhost:4000 \ No newline at end of file diff --git a/apps/web/package.json b/apps/web/package.json index db07fdd..787d55f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,10 +10,12 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@better-auth/passkey": "^1.7.2", "@gruperly/shared": "workspace:*", "@hookform/resolvers": "^3.9.0", "@tanstack/react-query": "^5.62.0", "@tanstack/react-router": "^1.90.0", + "better-auth": "1.7.2", "clsx": "^2.1.1", "lucide-react": "^1.34.0", "react": "^19.0.0", diff --git a/apps/web/src/components/auth/AuthShell.tsx b/apps/web/src/components/auth/AuthShell.tsx new file mode 100644 index 0000000..a0c0158 --- /dev/null +++ b/apps/web/src/components/auth/AuthShell.tsx @@ -0,0 +1,26 @@ +import type { ReactNode } from 'react' +import { Logo, LogoIcon } from '../brand' + +export type AuthShellProps = { + title: string + subtitle?: string + children: ReactNode +} + +export function AuthShell({ title, subtitle, children }: AuthShellProps) { + return ( +
+
+
+ + +
+
+

{title}

+ {subtitle ?

{subtitle}

: null} +
{children}
+
+
+
+ ) +} \ No newline at end of file diff --git a/apps/web/src/components/auth/index.ts b/apps/web/src/components/auth/index.ts new file mode 100644 index 0000000..ecfc46d --- /dev/null +++ b/apps/web/src/components/auth/index.ts @@ -0,0 +1 @@ +export { AuthShell } from './AuthShell' \ No newline at end of file diff --git a/apps/web/src/components/layout/Breadcrumb.tsx b/apps/web/src/components/layout/Breadcrumb.tsx new file mode 100644 index 0000000..73d3a9b --- /dev/null +++ b/apps/web/src/components/layout/Breadcrumb.tsx @@ -0,0 +1,62 @@ +import { Link, useLocation } from '@tanstack/react-router' +import { ChevronRight } from 'lucide-react' + +type Crumb = { label: string; to?: string } + +const BREADCRUMBS: Record = { + '/': [{ label: 'Inicio' }], + '/groups': [ + { label: 'Inicio', to: '/' }, + { label: 'Grupos' }, + ], + '/payments': [ + { label: 'Inicio', to: '/' }, + { label: 'Cobros' }, + ], + '/settings': [ + { label: 'Inicio', to: '/' }, + { label: 'Ajustes' }, + ], + '/seguridad': [ + { label: 'Inicio', to: '/' }, + { label: 'Ajustes', to: '/settings' }, + { label: 'Seguridad' }, + ], + '/settings/organizations': [ + { label: 'Inicio', to: '/' }, + { label: 'Ajustes', to: '/settings' }, + { label: 'Organizaciones' }, + ], + '/profile': [ + { label: 'Inicio', to: '/' }, + { label: 'Mi perfil' }, + ], +} + +export function Breadcrumb() { + const location = useLocation() + const crumbs = BREADCRUMBS[location.pathname] ?? [{ label: 'Inicio' }] + + return ( + + ) +} \ No newline at end of file diff --git a/apps/web/src/components/layout/Header.tsx b/apps/web/src/components/layout/Header.tsx new file mode 100644 index 0000000..73acf79 --- /dev/null +++ b/apps/web/src/components/layout/Header.tsx @@ -0,0 +1,20 @@ +import { Logo, LogoIcon } from '../brand' +import { Breadcrumb } from './Breadcrumb' +import { UserMenu } from './UserMenu' + +export function Header() { + return ( +
+
+
+ + + + + +
+ +
+
+ ) +} \ No newline at end of file diff --git a/apps/web/src/components/layout/RootLayout.tsx b/apps/web/src/components/layout/RootLayout.tsx index b063559..65ce4d7 100644 --- a/apps/web/src/components/layout/RootLayout.tsx +++ b/apps/web/src/components/layout/RootLayout.tsx @@ -1,44 +1,22 @@ import { Outlet } from '@tanstack/react-router' -import { Bell } from 'lucide-react' -import { Logo, LogoIcon } from '../brand' -import { Avatar, Button } from '../ui' +import { Header } from './Header' import { Sidebar } from './Sidebar' import { BottomNav } from './BottomNav' export function RootLayout() { return ( -
- +
+
-
-
-
- - - - -
- - -
-
-
+
+ -
+
- -
+ +
) -} +} \ No newline at end of file diff --git a/apps/web/src/components/layout/Sidebar.tsx b/apps/web/src/components/layout/Sidebar.tsx index 4c4daef..9736a67 100644 --- a/apps/web/src/components/layout/Sidebar.tsx +++ b/apps/web/src/components/layout/Sidebar.tsx @@ -7,7 +7,7 @@ export function Sidebar() { const location = useLocation() return ( -