diff --git a/backend/.env.example b/backend/.env.example index b6276ca..77e74a8 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,23 +1,41 @@ # Database -DATABASE_URL=postgresql://dyolink_user:password@postgres:5432/dyolink_db -POSTGRES_HOST=postgres +# +# Local dev (Nest on your machine + Postgres via docker-compose.postgres.yml): +# Use host "localhost" — hostname "postgres" only works inside Docker networks. +DATABASE_URL=postgresql://dyolink_user:password@localhost:5432/dyolink_db +POSTGRES_HOST=localhost POSTGRES_PORT=5432 POSTGRES_USER=dyolink_user -POSTGRES_PASSWORD=CHANGE_ME_IN_PRODUCTION +POSTGRES_PASSWORD=password POSTGRES_DB=dyolink_db +# +# If you run the API inside the same Compose stack as Postgres, use instead: +# DATABASE_URL=postgresql://dyolink_user:password@postgres:5432/dyolink_db +# POSTGRES_HOST=postgres -# JWT +# JWT (required for register/login) JWT_SECRET=CHANGE_ME_TO_A_STRONG_SECRET_32_CHARS_MIN JWT_EXPIRES_IN=7d +JWT_REFRESH_SECRET=CHANGE_ME_TO_ANOTHER_STRONG_SECRET +JWT_REFRESH_EXPIRES_IN=30d # Application PORT=3000 NODE_ENV=development API_PREFIX=/api -CORS_ORIGIN=http://localhost:3000 +# CORS and invite links — must match the URL where the Next.js app runs +FRONTEND_URL=http://localhost:3001 + +# OAuth (optional — uncomment when configured) +# GOOGLE_CLIENT_ID=your-google-client-id +# GOOGLE_CLIENT_SECRET=your-google-client-secret +# GOOGLE_CALLBACK_URL=http://localhost:3000/auth/google/callback +# FACEBOOK_CLIENT_ID=your-facebook-app-id +# FACEBOOK_CLIENT_SECRET=your-facebook-app-secret +# FACEBOOK_CALLBACK_URL=http://localhost:3000/auth/facebook/callback # Email (configure for production) SMTP_HOST=smtp.gmail.com SMTP_PORT=587 SMTP_USER=your_email@gmail.com -SMTP_PASSWORD=your_app_password \ No newline at end of file +SMTP_PASSWORD=your_app_password diff --git a/backend/README.md b/backend/README.md index 4fda133..b44f12f 100644 --- a/backend/README.md +++ b/backend/README.md @@ -3,7 +3,7 @@ ## Prerequisites - **Node.js 20+** and **npm** -- **PostgreSQL** reachable from your machine (local or remote) +- **PostgreSQL** reachable from your machine — either installed locally **or** run via Docker (see below) ## First-time setup @@ -28,9 +28,23 @@ - `JWT_SECRET` — strong secret for signing tokens Do not commit `.env`. -4. **Database URL for local dev** +4. **Database for local dev** - Point `DATABASE_URL` at a database you created in Postgres (create an empty DB first if needed). + **Option A — Postgres in Docker (no local install, e.g. Mac)** + From `backend/`, with `.env` present (copy from `.env.example` first): + + - Ensure `DATABASE_URL` uses **`localhost`** as the host (not `postgres`). Match user, password, and DB name to `POSTGRES_USER`, `POSTGRES_PASSWORD`, and `POSTGRES_DB` in the same file. + + ```bash + docker compose -f docker-compose.postgres.yml up -d + ``` + + Wait until Postgres is healthy (`docker compose -f docker-compose.postgres.yml ps`). The container creates the database on first start. + + To stop Postgres (data is kept in the named volume): `docker compose -f docker-compose.postgres.yml down` + + **Option B — Postgres installed on the machine** + Create an empty database, then point `DATABASE_URL` at it. 5. **Generate Prisma Client** @@ -46,12 +60,43 @@ This runs `prisma migrate dev`. Use it during development when the schema changes. -7. **Seed** (optional — sample data / bootstrap) +7. **Seed** (optional — reference data only) ```bash npm run prisma:seed ``` + This **does not** wipe your database. It only upserts lookup data: organization types (`CLINIC`, `LAB`), subscription plans, and tab permissions. Existing users, organizations, memberships, patients, appointments, and links are **left unchanged**. + + To start from an empty database with fresh tables and reference data, see [Reset database (clean slate)](#reset-database-clean-slate) below. + +## Reset database (clean slate) + +Use this when you want to **delete all application data** (users, organizations, patients, sessions, etc.) and rebuild the schema from migrations, then run the seed. + +From `backend/`: + +```bash +npx prisma migrate reset +``` + +Prisma will prompt for confirmation, drop the database, re-apply all migrations, and run `prisma/seed.ts` automatically. + +**What gets removed:** everything in the database, including organizations and all related rows. + +**What the seed adds back:** only reference data (types, plans, permissions) — not demo users or organizations. Register again or use your own test data after a reset. + +**Docker Postgres dev:** if you also want to wipe the Docker volume (not only tables), stop the container and remove the volume: + +```bash +docker compose -f docker-compose.postgres.yml down -v +docker compose -f docker-compose.postgres.yml up -d +npm run prisma:migrate +npm run prisma:seed +``` + +Do **not** run `migrate reset` against production or shared staging databases. + ## Run (development) ```bash @@ -60,7 +105,7 @@ npm run start:dev API listens on **`http://localhost:3000`** by default (`PORT` in `.env`). -If the frontend runs on another origin (e.g. `http://localhost:3001`), set `CORS_ORIGIN` in `.env` to that URL. +If the frontend runs on another origin (e.g. `http://localhost:3001`), set `FRONTEND_URL` in `.env` to that URL (CORS and invite links use it). ## After pulling latest `main` @@ -71,7 +116,7 @@ npm run prisma:generate npm run prisma:migrate ``` -If teammates added migrations, step 4 applies them. Resolve migration conflicts locally before pushing. +If teammates added migrations, the migrate step above applies them. Resolve migration conflicts locally before pushing. ## Useful commands @@ -80,9 +125,16 @@ If teammates added migrations, step 4 applies them. Resolve migration conflicts | `npm run prisma:generate` | Regenerate client after `schema.prisma` changes | | `npm run prisma:migrate` | Dev migrations (`migrate dev`) | | `npm run prisma:deploy` | Production-style apply (`migrate deploy`) — e.g. CI/containers | +| `npm run prisma:seed` | Upsert reference data only (does not clear existing rows) | +| `npx prisma migrate reset` | Drop DB, re-migrate, run seed — **dev clean slate** | | `npm run build` | Compile Nest app | | `npm run start:prod` | Run compiled app (`node dist/main`) | ## Docker -Image build is defined in **`Dockerfile`** at this folder. For full-stack deployment and CI, see the **repository root `README.md`**. +| File | Purpose | +|------|--------| +| **`Dockerfile`** | Production API image | +| **`docker-compose.postgres.yml`** | Local dev Postgres only (port mapped to host) | + +For full-stack deployment and CI, see the **repository root `README.md`**. diff --git a/backend/docker-compose.postgres.yml b/backend/docker-compose.postgres.yml new file mode 100644 index 0000000..405d992 --- /dev/null +++ b/backend/docker-compose.postgres.yml @@ -0,0 +1,29 @@ +# Local development Postgres only. +# Run from backend/: docker compose -f docker-compose.postgres.yml up -d +# +# Nest runs on your Mac/PC; use DATABASE_URL with host "localhost" (not "postgres"). +# Variables POSTGRES_* and POSTGRES_PORT are read from .env (see .env.example). + +services: + postgres: + image: postgres:15-alpine + container_name: dyolink-postgres-dev + restart: unless-stopped + ports: + - "${POSTGRES_PORT:-5432}:5432" + environment: + POSTGRES_USER: ${POSTGRES_USER:-dyolink_user} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-password} + POSTGRES_DB: ${POSTGRES_DB:-dyolink_db} + volumes: + - dyolink_pgdata_dev:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 10s + +volumes: + dyolink_pgdata_dev: + name: dyolink_postgres_data_dev diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 3d74145..7882769 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -217,6 +217,17 @@ model Feature { @@map("features") } +/// Bidirectional clinic↔lab relationship. One row per unordered pair (A id < B id). +/// +/// Two product flows share this table: +/// 1. **Connection request** — inviter found an existing subscribed org in search; row is PENDING +/// until the counterpart accepts. No OrganizationInvitation row. +/// 2. **Invitation link** — inviter could not find the org; inviteOrganization() creates a +/// placeholder org, an OrganizationInvitation (signup token), and a PENDING link here so the +/// inviter does not need a second request after signup. acceptInvite() sets the link to ACTIVE. +/// +/// `sharedDataTypes` stores metadata (not shared clinical data yet). While PENDING, entries like +/// `requested_by:{orgId}` record who initiated the request (see OrganizationService). model OrganizationLink { id String @id @default(uuid()) @@ -235,6 +246,16 @@ model OrganizationLink { @@map("organization_links") } +/// Signup invite for a counterpart org that is not on DyoLink yet (or has no active subscription). +/// Complements OrganizationLink: invite flow always creates both records in one transaction. +/// +/// Only the token *hash* is stored; the plain token is returned once on create/regenerate and may +/// be cached in the browser (see frontend useOrganizationInviteLinkCopy). Regenerating rotates +/// tokenHash and expiresAt on the same invitation row. +/// +/// `invitedOrganizationId` points at a placeholder Organization (pending-* email) until accept; +/// list() joins open invitations to links so the UI can offer "copy invitation link" on the +/// pending connection row (pendingInvitationId on the API response). model OrganizationInvitation { id String @id @default(uuid()) @@ -278,6 +299,8 @@ model Session { @@map("sessions") } +/// OrganizationLink lifecycle. Invitation rows use overlapping semantics in API mappers +/// (e.g. accepted invitation → ACTIVE in listInvitationHistory). enum LinkStatus { PENDING ACTIVE diff --git a/backend/src/configs/configurations.ts b/backend/src/configs/configurations.ts index 2e36ccc..e4d1c38 100644 --- a/backend/src/configs/configurations.ts +++ b/backend/src/configs/configurations.ts @@ -1,4 +1,36 @@ // backend/src/config/configuration.ts + +/** Matches values accepted by jsonwebtoken `expiresIn` (via ms), e.g. 7d, 15m, or plain seconds. */ +const JWT_TIMESPAN_PATTERN = + /^\d+(\.\d+)?(ms|s|m|h|d|w|y)?$/i; + +function assertJwtSecret(value: string, envKey: string): string { + const trimmed = value.trim(); + if (!trimmed) { + throw new Error(`❌ Environment variable ${envKey} is required but not set`); + } + if (trimmed.length < 16) { + throw new Error(`❌ ${envKey} must be at least 16 characters`); + } + if (/CHANGE_ME/i.test(trimmed)) { + throw new Error(`❌ ${envKey} must be changed from the placeholder value`); + } + return trimmed; +} + +function assertJwtTimespan(value: string, envKey: string): string { + const trimmed = value.trim(); + if (!trimmed) { + throw new Error(`❌ Environment variable ${envKey} is required but not set`); + } + if (!/^\d+$/.test(trimmed) && !JWT_TIMESPAN_PATTERN.test(trimmed)) { + throw new Error( + `❌ ${envKey}="${value}" is invalid. Use a duration like 7d, 15m, 30d, or a number of seconds.`, + ); + } + return trimmed; +} + export interface Config { port: number; database: { @@ -7,6 +39,8 @@ export interface Config { jwt: { secret: string; expiresIn: string; + refreshSecret: string; + refreshExpiresIn: string; }; throttle: { ttl: number; @@ -24,9 +58,10 @@ export default (): Config => { return value; }; - // Helper for optional env vars with defaults + // Helper for optional env vars with defaults (whitespace-only counts as unset) const getEnvVarWithDefault = (key: string, defaultValue: string): string => { - return process.env[key] || defaultValue; + const value = process.env[key]?.trim(); + return value ? value : defaultValue; }; const getEnvVarAsNumber = (key: string, defaultValue: number): number => { @@ -36,14 +71,30 @@ export default (): Config => { return isNaN(parsed) ? defaultValue : parsed; }; + const jwtSecret = assertJwtSecret(getEnvVar('JWT_SECRET'), 'JWT_SECRET'); + const jwtExpiresIn = assertJwtTimespan( + getEnvVarWithDefault('JWT_EXPIRES_IN', '7d'), + 'JWT_EXPIRES_IN', + ); + const jwtRefreshSecret = assertJwtSecret( + getEnvVarWithDefault('JWT_REFRESH_SECRET', jwtSecret), + 'JWT_REFRESH_SECRET', + ); + const jwtRefreshExpiresIn = assertJwtTimespan( + getEnvVarWithDefault('JWT_REFRESH_EXPIRES_IN', '30d'), + 'JWT_REFRESH_EXPIRES_IN', + ); + return { port: getEnvVarAsNumber('PORT', 3000), database: { url: getEnvVar('DATABASE_URL'), }, jwt: { - secret: getEnvVar('JWT_SECRET'), - expiresIn: getEnvVarWithDefault('JWT_EXPIRES_IN', '7d'), + secret: jwtSecret, + expiresIn: jwtExpiresIn, + refreshSecret: jwtRefreshSecret, + refreshExpiresIn: jwtRefreshExpiresIn, }, throttle: { ttl: getEnvVarAsNumber('THROTTLE_TTL', 60), diff --git a/backend/src/modules/appointments/appointments.controller.ts b/backend/src/modules/appointments/appointments.controller.ts index 6732028..8c32934 100644 --- a/backend/src/modules/appointments/appointments.controller.ts +++ b/backend/src/modules/appointments/appointments.controller.ts @@ -1,9 +1,21 @@ -import { Body, Controller, Delete, Get, Param, Post, Query, Req, UseGuards } from '@nestjs/common'; +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + Req, + UseGuards, +} from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { AppointmentsService } from './appointments.service'; import { CreateAppointmentDto } from './dto/create-appointment.dto'; import { ListAppointmentsDto } from './dto/list-appointments.dto'; +import { UpdateAppointmentDto } from './dto/update-appointment.dto'; @ApiTags('appointments') @ApiBearerAuth('JWT-auth') @@ -39,6 +51,17 @@ export class AppointmentsController { return this.appointmentsService.create(dto, organizationId, req.user.id); } + @Patch(':id') + @ApiOperation({ summary: 'Update appointment (requires TAB_APPOINTMENTS_EDIT or owner)' }) + update( + @Param('id') id: string, + @Body() dto: UpdateAppointmentDto, + @Req() req: { user: { id: string; organizationId?: string } }, + ) { + const organizationId = this.appointmentsService.getOrganizationIdFromUser(req.user); + return this.appointmentsService.update(id, dto, organizationId, req.user.id); + } + @Delete(':id') @ApiOperation({ summary: 'Delete appointment (requires TAB_APPOINTMENTS_EDIT or owner)' }) remove( diff --git a/backend/src/modules/appointments/appointments.service.ts b/backend/src/modules/appointments/appointments.service.ts index c36d5e6..2f9e48d 100644 --- a/backend/src/modules/appointments/appointments.service.ts +++ b/backend/src/modules/appointments/appointments.service.ts @@ -7,6 +7,7 @@ import { import { PrismaService } from '../../../prisma/prisma.service'; import { CreateAppointmentDto } from './dto/create-appointment.dto'; import { ListAppointmentsDto } from './dto/list-appointments.dto'; +import { UpdateAppointmentDto } from './dto/update-appointment.dto'; const MS_PER_DAY = 86_400_000; @@ -109,20 +110,6 @@ export class AppointmentsService { await this.ensurePatientInOrg(dto.patientId, organizationId); await this.ensureProviderIsTreatmentEditor(dto.providerUserId, organizationId); - const overlap = await this.prisma.appointment.findFirst({ - where: { - organizationId, - providerUserId: dto.providerUserId, - startAt: { lt: endAt }, - endAt: { gt: startAt }, - }, - select: { id: true }, - }); - - if (overlap) { - throw new BadRequestException('This time slot overlaps an existing appointment for that provider'); - } - const appointment = await this.prisma.appointment.create({ data: { organizationId, @@ -142,6 +129,63 @@ export class AppointmentsService { return { success: true, data: appointment }; } + async update( + id: string, + dto: UpdateAppointmentDto, + organizationId: string, + actorUserId: string, + ) { + await this.assertCanEditAppointments(actorUserId, organizationId); + + const existing = await this.prisma.appointment.findFirst({ + where: { id, organizationId }, + }); + + if (!existing) { + throw new NotFoundException('Appointment not found'); + } + + const startAt = dto.startAt ? new Date(dto.startAt) : existing.startAt; + const endAt = dto.endAt ? new Date(dto.endAt) : existing.endAt; + + if (Number.isNaN(startAt.getTime()) || Number.isNaN(endAt.getTime())) { + throw new BadRequestException('Invalid start or end time'); + } + + if (endAt <= startAt) { + throw new BadRequestException('End time must be after start time'); + } + + if (endAt.getTime() - startAt.getTime() > MS_PER_DAY) { + throw new BadRequestException('Appointment cannot span more than 24 hours'); + } + + const patientId = dto.patientId ?? existing.patientId; + const providerUserId = dto.providerUserId ?? existing.providerUserId; + const purpose = dto.purpose ?? existing.purpose; + + await this.ensurePatientInOrg(patientId, organizationId); + await this.ensureProviderIsTreatmentEditor(providerUserId, organizationId); + + const appointment = await this.prisma.appointment.update({ + where: { id }, + data: { + patientId, + providerUserId, + startAt, + endAt, + purpose, + }, + include: { + patient: { + select: { id: true, firstName: true, lastName: true, phone: true }, + }, + }, + }); + + return { success: true, data: appointment }; + } + async remove(id: string, organizationId: string, actorUserId: string) { await this.assertCanEditAppointments(actorUserId, organizationId); diff --git a/backend/src/modules/appointments/dto/update-appointment.dto.ts b/backend/src/modules/appointments/dto/update-appointment.dto.ts new file mode 100644 index 0000000..e743fb4 --- /dev/null +++ b/backend/src/modules/appointments/dto/update-appointment.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateAppointmentDto } from './create-appointment.dto'; + +export class UpdateAppointmentDto extends PartialType(CreateAppointmentDto) {} diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 368624f..7e3a4a1 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -7,6 +7,7 @@ import { InternalServerErrorException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; +import type { JwtSignOptions } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; import * as bcrypt from 'bcrypt'; import { PrismaService } from '../../../prisma/prisma.service'; @@ -53,6 +54,20 @@ export class AuthService { private configService: ConfigService, ) { } + private accessJwtSignOptions(): JwtSignOptions { + return { + secret: this.configService.get('jwt.secret')!, + expiresIn: this.configService.get('jwt.expiresIn')!, + } as JwtSignOptions; + } + + private refreshJwtSignOptions(): JwtSignOptions { + return { + secret: this.configService.get('jwt.refreshSecret')!, + expiresIn: this.configService.get('jwt.refreshExpiresIn')!, + } as JwtSignOptions; + } + /** * Validate user credentials (used by LocalStrategy) * @param email - User's email @@ -128,14 +143,8 @@ export class AuthService { }; const [accessToken, refreshToken] = await Promise.all([ - this.jwtService.signAsync(accessPayload, { - secret: this.configService.get('JWT_SECRET'), - expiresIn: this.configService.get('JWT_EXPIRES_IN'), - }), - this.jwtService.signAsync(refreshPayload, { - secret: this.configService.get('JWT_REFRESH_SECRET'), - expiresIn: this.configService.get('JWT_REFRESH_EXPIRES_IN'), - }), + this.jwtService.signAsync(accessPayload, this.accessJwtSignOptions()), + this.jwtService.signAsync(refreshPayload, this.refreshJwtSignOptions()), ]); // Store session in database @@ -398,7 +407,7 @@ export class AuthService { try { // Verify the refresh token const payload = await this.jwtService.verifyAsync(refreshToken, { - secret: this.configService.get('jwt.refreshSecret'), + secret: this.configService.get('jwt.refreshSecret'), }); // Ensure this is a refresh token @@ -446,10 +455,10 @@ export class AuthService { type: 'access', }; - const newAccessToken = await this.jwtService.signAsync(newAccessPayload, { - secret: this.configService.get('jwt.secret'), - expiresIn: this.configService.get('jwt.expiresIn'), - }); + const newAccessToken = await this.jwtService.signAsync( + newAccessPayload, + this.accessJwtSignOptions(), + ); // Update session with new access token await this.prisma.session.update({ @@ -732,10 +741,7 @@ export class AuthService { }; // 3. Generate new token - const accessToken = await this.jwtService.signAsync(payload, { - secret: this.configService.get('JWT_SECRET'), - expiresIn: this.configService.get('JWT_EXPIRES_IN'), - }); + const accessToken = await this.jwtService.signAsync(payload, this.accessJwtSignOptions()); // 4. Format permissions const permissions = this.getMembershipPermissions(membership); diff --git a/backend/src/modules/auth/strategies/jwt.strategy.ts b/backend/src/modules/auth/strategies/jwt.strategy.ts index faaaff3..412ad61 100644 --- a/backend/src/modules/auth/strategies/jwt.strategy.ts +++ b/backend/src/modules/auth/strategies/jwt.strategy.ts @@ -17,7 +17,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { return req?.cookies?.accessToken; // ✅ READ FROM COOKIE }, ignoreExpiration: false, - secretOrKey: configService.get('JWT_SECRET'), + secretOrKey: configService.get('jwt.secret'), }); } diff --git a/backend/src/modules/organization/dto/accept-organization-invite.dto.ts b/backend/src/modules/organization/dto/accept-organization-invite.dto.ts index 4b4f692..2ef24f4 100644 --- a/backend/src/modules/organization/dto/accept-organization-invite.dto.ts +++ b/backend/src/modules/organization/dto/accept-organization-invite.dto.ts @@ -1,4 +1,4 @@ -import { IsString, MinLength } from 'class-validator'; +import { IsEmail, IsEnum, IsString, MinLength } from 'class-validator'; export class AcceptOrganizationInviteDto { @IsString() @@ -9,6 +9,12 @@ export class AcceptOrganizationInviteDto { @MinLength(1) organizationName: string; + @IsEmail() + organizationEmail: string; + + @IsEnum(['CLINIC', 'LAB']) + organizationType: 'CLINIC' | 'LAB'; + @IsString() @MinLength(1) ownerName: string; diff --git a/backend/src/modules/organization/dto/create-connection-request.dto.ts b/backend/src/modules/organization/dto/create-connection-request.dto.ts new file mode 100644 index 0000000..a65b7b9 --- /dev/null +++ b/backend/src/modules/organization/dto/create-connection-request.dto.ts @@ -0,0 +1,7 @@ +import { IsUUID } from 'class-validator'; + +/** Existing subscribed counterpart org (search result). Does not create an OrganizationInvitation. */ +export class CreateConnectionRequestDto { + @IsUUID() + targetOrganizationId: string; +} diff --git a/backend/src/modules/organization/dto/create-link-request.dto.ts b/backend/src/modules/organization/dto/create-link-request.dto.ts deleted file mode 100644 index 40a0132..0000000 --- a/backend/src/modules/organization/dto/create-link-request.dto.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { IsUUID } from 'class-validator'; - -export class CreateLinkRequestDto { - @IsUUID() - targetOrganizationId: string; -} diff --git a/backend/src/modules/organization/dto/invite-organization.dto.ts b/backend/src/modules/organization/dto/invite-organization.dto.ts index 469a529..706ff96 100644 --- a/backend/src/modules/organization/dto/invite-organization.dto.ts +++ b/backend/src/modules/organization/dto/invite-organization.dto.ts @@ -1,5 +1,6 @@ import { IsEmail, IsOptional, IsString, MinLength } from 'class-validator'; +/** Starts invitation-link flow: creates OrganizationInvitation + PENDING OrganizationLink. */ export class InviteOrganizationDto { @IsString() @MinLength(1) diff --git a/backend/src/modules/organization/dto/respond-connection-request.dto.ts b/backend/src/modules/organization/dto/respond-connection-request.dto.ts new file mode 100644 index 0000000..e9f6253 --- /dev/null +++ b/backend/src/modules/organization/dto/respond-connection-request.dto.ts @@ -0,0 +1,7 @@ +import { IsIn } from 'class-validator'; + +/** Counterpart org accepts or declines an incoming OrganizationLink (connection request). */ +export class RespondConnectionRequestDto { + @IsIn(['ACCEPT', 'REJECT']) + action: 'ACCEPT' | 'REJECT'; +} diff --git a/backend/src/modules/organization/dto/respond-link-request.dto.ts b/backend/src/modules/organization/dto/respond-link-request.dto.ts deleted file mode 100644 index 02086c3..0000000 --- a/backend/src/modules/organization/dto/respond-link-request.dto.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { IsIn } from 'class-validator'; - -export class RespondLinkRequestDto { - @IsIn(['ACCEPT', 'REJECT']) - action: 'ACCEPT' | 'REJECT'; -} diff --git a/backend/src/modules/organization/organization.controller.ts b/backend/src/modules/organization/organization.controller.ts index 6a3152f..7e56c60 100644 --- a/backend/src/modules/organization/organization.controller.ts +++ b/backend/src/modules/organization/organization.controller.ts @@ -13,12 +13,18 @@ import { import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { AcceptOrganizationInviteDto } from './dto/accept-organization-invite.dto'; -import { CreateLinkRequestDto } from './dto/create-link-request.dto'; +import { CreateConnectionRequestDto } from './dto/create-connection-request.dto'; import { InviteOrganizationDto } from './dto/invite-organization.dto'; import { PreviewOrganizationInviteDto } from './dto/preview-organization-invite.dto'; -import { RespondLinkRequestDto } from './dto/respond-link-request.dto'; +import { RespondConnectionRequestDto } from './dto/respond-connection-request.dto'; import { OrganizationService } from './organization.service'; +/** + * Counterpart orgs (clinic↔lab). + * + * - `/connections` — OrganizationLink rows (connection requests + links created by invites). + * - `/invite`, `/invitations/*` — signup invitation tokens (orgs not yet on DyoLink). + */ @ApiTags('organizations') @ApiBearerAuth('JWT-auth') @Controller('organizations') @@ -58,46 +64,53 @@ export class OrganizationController { return this.organizationService.searchCounterpartOrganizations(req.user.id, organizationId, q); } - @Get('links') + @Get('connections') @UseGuards(JwtAuthGuard) - @ApiOperation({ summary: 'List counterpart links and invitations for current org' }) - list(@Req() req: { user: { id: string; organizationId?: string } }) { + @ApiOperation({ summary: 'List counterpart connections for current organization' }) + listConnections(@Req() req: { user: { id: string; organizationId?: string } }) { const organizationId = this.organizationService.getOrganizationIdFromUser(req.user); return this.organizationService.list(req.user.id, organizationId); } - @Post('links') + @Post('connections') @UseGuards(JwtAuthGuard) - @ApiOperation({ summary: 'Create pending link request to an existing subscribed counterpart org' }) - createLinkRequest( + @ApiOperation({ + summary: 'Create pending connection request to an existing subscribed counterpart org', + }) + createConnectionRequest( @Req() req: { user: { id: string; organizationId?: string } }, - @Body() dto: CreateLinkRequestDto, + @Body() dto: CreateConnectionRequestDto, ) { const organizationId = this.organizationService.getOrganizationIdFromUser(req.user); - return this.organizationService.createLinkRequest(req.user.id, organizationId, dto); + return this.organizationService.createConnectionRequest(req.user.id, organizationId, dto); } - @Patch('links/:linkId/respond') + @Patch('connections/:connectionId/respond') @UseGuards(JwtAuthGuard) - @ApiOperation({ summary: 'Accept or reject a pending link request for current organization' }) - respondToLinkRequest( + @ApiOperation({ summary: 'Accept or reject a pending connection request for current organization' }) + respondToConnectionRequest( @Req() req: { user: { id: string; organizationId?: string } }, - @Param('linkId') linkId: string, - @Body() dto: RespondLinkRequestDto, + @Param('connectionId') connectionId: string, + @Body() dto: RespondConnectionRequestDto, ) { const organizationId = this.organizationService.getOrganizationIdFromUser(req.user); - return this.organizationService.respondToLinkRequest(req.user.id, organizationId, linkId, dto); + return this.organizationService.respondToConnectionRequest( + req.user.id, + organizationId, + connectionId, + dto, + ); } - @Delete('links/:linkId') + @Delete('connections/:connectionId') @UseGuards(JwtAuthGuard) - @ApiOperation({ summary: 'Delete linked organization record' }) - deleteLink( + @ApiOperation({ summary: 'Remove an active connection' }) + deleteConnection( @Req() req: { user: { id: string; organizationId?: string } }, - @Param('linkId') linkId: string, + @Param('connectionId') connectionId: string, ) { const organizationId = this.organizationService.getOrganizationIdFromUser(req.user); - return this.organizationService.deleteLink(req.user.id, organizationId, linkId); + return this.organizationService.deleteConnection(req.user.id, organizationId, connectionId); } @Post('invitations/:invitationId/link') diff --git a/backend/src/modules/organization/organization.service.ts b/backend/src/modules/organization/organization.service.ts index ce8d967..5198d77 100644 --- a/backend/src/modules/organization/organization.service.ts +++ b/backend/src/modules/organization/organization.service.ts @@ -10,10 +10,22 @@ import * as bcrypt from 'bcrypt'; import { createHash, randomBytes } from 'crypto'; import { PrismaService } from '../../../prisma/prisma.service'; import { AcceptOrganizationInviteDto } from './dto/accept-organization-invite.dto'; -import { CreateLinkRequestDto } from './dto/create-link-request.dto'; +import { CreateConnectionRequestDto } from './dto/create-connection-request.dto'; import { InviteOrganizationDto } from './dto/invite-organization.dto'; -import { RespondLinkRequestDto } from './dto/respond-link-request.dto'; +import { RespondConnectionRequestDto } from './dto/respond-connection-request.dto'; +/** + * Clinic↔lab counterpart relationships. + * + * **Connection request** (`createConnectionRequest`): target org already exists with a subscription. + * Creates OrganizationLink PENDING only; counterpart accepts via `respondToConnectionRequest`. + * + * **Invitation link** (`inviteOrganization`): target not in directory (no subscription). Creates + * placeholder Organization + OrganizationInvitation + PENDING OrganizationLink in one transaction. + * Invitee signs up via `acceptInvite`, which activates the link—no second connection request needed. + * + * API name is "connection"; Prisma model remains `OrganizationLink` (historical table name). + */ @Injectable() export class OrganizationService { constructor(private readonly prisma: PrismaService) {} @@ -62,13 +74,16 @@ export class OrganizationService { return { success: true, data: organizations }; } + /** Connections list for the Organizations tab (both sides of each link). */ async list(userId: string, organizationId: string) { const actor = await this.getActorMembership(userId, organizationId); if (!actor || !this.canEditOrganizations(actor)) { throw new ForbiddenException('You do not have permission to manage organizations'); } - const [linksA, linksB] = await Promise.all([ + // Open outbound invitations keyed by placeholder/real invited org id — lets UI show copy-invite + // on the auto-created PENDING link without opening invitation history. + const [linksA, linksB, outboundInvitations] = await Promise.all([ this.prisma.organizationLink.findMany({ where: { organizationAId: organizationId }, include: { @@ -83,31 +98,56 @@ export class OrganizationService { }, orderBy: { createdAt: 'desc' }, }), + this.prisma.organizationInvitation.findMany({ + where: { + inviterOrganizationId: organizationId, + acceptedAt: null, + revokedAt: null, + invitedOrganizationId: { not: null }, + }, + select: { + id: true, + invitedOrganizationId: true, + invitedOwnerEmail: true, + expiresAt: true, + acceptedAt: true, + revokedAt: true, + }, + }), ]); + const invitationByOrgId = new Map( + outboundInvitations + .filter((inv) => inv.invitedOrganizationId) + .map((inv) => [inv.invitedOrganizationId as string, inv]), + ); + + const mapLinkItem = ( + l: (typeof linksA)[number] | (typeof linksB)[number], + counterpart: { id: string; name: string; email: string; phone: string | null }, + ) => { + const invitation = invitationByOrgId.get(counterpart.id); + return { + requestedByOrganizationId: this.getRequesterOrganizationId(l.sharedDataTypes), + id: l.id, + counterpartOrganizationId: counterpart.id, + organizationName: counterpart.name, + ownerEmail: invitation?.invitedOwnerEmail ?? counterpart.email, + phone: counterpart.phone, + status: l.status, + createdAt: l.createdAt.toISOString(), + acceptedAt: l.status === LinkStatus.ACTIVE ? l.updatedAt.toISOString() : null, + // Present only for invite-flow pending links (see inviteOrganization). + pendingInvitationId: invitation?.id ?? null, + invitationStatus: invitation + ? this.mapInvitationStatus(invitation.acceptedAt, invitation.revokedAt, invitation.expiresAt) + : null, + }; + }; + const linkItems = [ - ...linksA.map((l) => ({ - requestedByOrganizationId: this.getRequesterOrganizationId(l.sharedDataTypes), - id: l.id, - counterpartOrganizationId: l.organizationB.id, - organizationName: l.organizationB.name, - ownerEmail: l.organizationB.email, - phone: l.organizationB.phone, - status: l.status, - createdAt: l.createdAt.toISOString(), - acceptedAt: l.status === LinkStatus.ACTIVE ? l.updatedAt.toISOString() : null, - })), - ...linksB.map((l) => ({ - requestedByOrganizationId: this.getRequesterOrganizationId(l.sharedDataTypes), - id: l.id, - counterpartOrganizationId: l.organizationA.id, - organizationName: l.organizationA.name, - ownerEmail: l.organizationA.email, - phone: l.organizationA.phone, - status: l.status, - createdAt: l.createdAt.toISOString(), - acceptedAt: l.status === LinkStatus.ACTIVE ? l.updatedAt.toISOString() : null, - })), + ...linksA.map((l) => mapLinkItem(l, l.organizationB)), + ...linksB.map((l) => mapLinkItem(l, l.organizationA)), ]; return { @@ -146,7 +186,12 @@ export class OrganizationService { }; } - async createLinkRequest(userId: string, organizationId: string, dto: CreateLinkRequestDto) { + /** Flow 1: request to connect with an org that already has planId (found via search). */ + async createConnectionRequest( + userId: string, + organizationId: string, + dto: CreateConnectionRequestDto, + ) { const actor = await this.getActorMembership(userId, organizationId); if (!actor || !this.canEditOrganizations(actor)) { throw new ForbiddenException('You do not have permission to manage organizations'); @@ -188,6 +233,7 @@ export class OrganizationService { organizationAId: aId, organizationBId: bId, status: LinkStatus.PENDING, + // Who initiated; counterpart uses this to block self-accept (see respondToConnectionRequest). sharedDataTypes: [`requested_by:${organizationId}`], }, }); @@ -195,79 +241,83 @@ export class OrganizationService { return { success: true, data: { id: created.id, status: created.status }, - message: 'Link request created', + message: 'Connection request created', }; } - async respondToLinkRequest( + async respondToConnectionRequest( userId: string, organizationId: string, - linkId: string, - dto: RespondLinkRequestDto, + connectionId: string, + dto: RespondConnectionRequestDto, ) { const actor = await this.getActorMembership(userId, organizationId); if (!actor || !this.canEditOrganizations(actor)) { throw new ForbiddenException('You do not have permission to manage organizations'); } - const link = await this.prisma.organizationLink.findFirst({ + const connection = await this.prisma.organizationLink.findFirst({ where: { - id: linkId, + id: connectionId, OR: [{ organizationAId: organizationId }, { organizationBId: organizationId }], }, }); - if (!link) { - throw new NotFoundException('Link request not found'); + if (!connection) { + throw new NotFoundException('Connection request not found'); } - if (link.status !== LinkStatus.PENDING) { - throw new BadRequestException('Only pending link requests can be responded to'); + if (connection.status !== LinkStatus.PENDING) { + throw new BadRequestException('Only pending connection requests can be responded to'); } - const requesterOrgId = this.getRequesterOrganizationId(link.sharedDataTypes); + const requesterOrgId = this.getRequesterOrganizationId(connection.sharedDataTypes); if (requesterOrgId && requesterOrgId === organizationId) { - throw new ForbiddenException('You cannot respond to your own link request'); + throw new ForbiddenException('You cannot respond to your own connection request'); } const nextStatus = dto.action === 'ACCEPT' ? LinkStatus.ACTIVE : LinkStatus.REJECTED; const updated = await this.prisma.organizationLink.update({ - where: { id: link.id }, + where: { id: connection.id }, data: { status: nextStatus }, }); return { success: true, data: { id: updated.id, status: updated.status }, - message: nextStatus === LinkStatus.ACTIVE ? 'Link request accepted' : 'Link request rejected', + message: + nextStatus === LinkStatus.ACTIVE + ? 'Connection request accepted' + : 'Connection request declined', }; } - async deleteLink(userId: string, organizationId: string, linkId: string) { + async deleteConnection(userId: string, organizationId: string, connectionId: string) { const actor = await this.getActorMembership(userId, organizationId); if (!actor || !this.canEditOrganizations(actor)) { throw new ForbiddenException('You do not have permission to manage organizations'); } - const link = await this.prisma.organizationLink.findFirst({ + const connection = await this.prisma.organizationLink.findFirst({ where: { - id: linkId, + id: connectionId, status: LinkStatus.ACTIVE, OR: [{ organizationAId: organizationId }, { organizationBId: organizationId }], }, select: { id: true }, }); - if (!link) { - throw new NotFoundException('Linked organization not found'); + if (!connection) { + throw new NotFoundException('Connected organization not found'); } - await this.prisma.organizationLink.delete({ where: { id: link.id } }); + await this.prisma.organizationLink.delete({ where: { id: connection.id } }); return { success: true, - data: { id: link.id }, - message: 'Linked organization removed', + data: { id: connection.id }, + message: 'Connection removed', }; } + /** Re-issue a shareable URL for a pending invitation (rotates token; previous URL stops working). */ async getInvitationLink(userId: string, organizationId: string, invitationId: string) { const actor = await this.getActorMembership(userId, organizationId); if (!actor || !this.canEditOrganizations(actor)) { @@ -288,8 +338,11 @@ export class OrganizationService { if (!invitation) { throw new NotFoundException('Invitation not found'); } - if (invitation.acceptedAt || invitation.revokedAt) { - throw new BadRequestException('Only pending invitations can provide a link'); + if (invitation.acceptedAt) { + throw new BadRequestException('This invitation has already been accepted'); + } + if (invitation.revokedAt) { + throw new BadRequestException('This invitation is no longer valid'); } const plainToken = this.generateInviteToken(); @@ -311,6 +364,10 @@ export class OrganizationService { }; } + /** + * Flow 2: invitation link when search finds no subscribed counterpart. + * Always creates/updates PENDING OrganizationLink + OrganizationInvitation together. + */ async inviteOrganization(userId: string, organizationId: string, dto: InviteOrganizationDto) { const actor = await this.getActorMembership(userId, organizationId); if (!actor || !this.canEditOrganizations(actor)) { @@ -358,6 +415,7 @@ export class OrganizationService { }); if (!invitedOrg) { + // Placeholder org until acceptInvite; real email is set on acceptance. invitedOrg = await tx.organization.create({ data: { name: dto.organizationName.trim(), @@ -381,6 +439,7 @@ export class OrganizationService { throw new ConflictException('These organizations are already linked'); } + // Pre-create connection so inviter sees one pending row; acceptInvite() flips to ACTIVE. await tx.organizationLink.upsert({ where: { organizationAId_organizationBId: { organizationAId: aId, organizationBId: bId } }, update: { @@ -409,6 +468,7 @@ export class OrganizationService { }); }); + // Plain token is only available here and after getInvitationLink; UI may cache it in localStorage. return { success: true, data: { @@ -421,12 +481,23 @@ export class OrganizationService { async previewInvite(token: string) { const invitation = await this.findValidInvitation(token); + let organizationEmail = ''; + if (invitation.invitedOrganizationId) { + const invitedOrg = await this.prisma.organization.findUnique({ + where: { id: invitation.invitedOrganizationId }, + select: { email: true }, + }); + if (invitedOrg?.email && !invitedOrg.email.includes('@dyolink.local')) { + organizationEmail = invitedOrg.email; + } + } return { success: true, data: { ownerEmail: invitation.invitedOwnerEmail, organizationName: invitation.invitedOrganizationName, organizationType: invitation.invitedOrganizationType, + organizationEmail, inviterOrganizationName: invitation.inviterOrganization.name, expiresAt: invitation.expiresAt.toISOString(), status: invitation.acceptedAt ? 'ACCEPTED' : 'PENDING', @@ -434,12 +505,21 @@ export class OrganizationService { }; } + /** Public signup completion: activates trial org and the pre-created OrganizationLink. */ async acceptInvite(dto: AcceptOrganizationInviteDto) { const invitation = await this.findValidInvitation(dto.token); if (invitation.acceptedAt) { throw new BadRequestException('This invitation has already been accepted'); } + if (dto.organizationType !== invitation.invitedOrganizationType) { + throw new BadRequestException( + `Organization type must be ${invitation.invitedOrganizationType} for this invitation`, + ); + } + + const organizationEmail = dto.organizationEmail.trim().toLowerCase(); + const organization = await this.prisma.$transaction(async (tx) => { const passwordHash = await bcrypt.hash(dto.password, 10); const ownerEmail = invitation.invitedOwnerEmail; @@ -467,8 +547,9 @@ export class OrganizationService { where: { id: targetOrganizationId }, data: { name: dto.organizationName.trim(), - email: ownerEmail, + email: organizationEmail, owner: { connect: { id: owner.id } }, + type: { connect: { name: dto.organizationType } }, plan: { connect: { name: 'trial' } }, }, }); @@ -476,9 +557,9 @@ export class OrganizationService { const createdOrg = await tx.organization.create({ data: { name: dto.organizationName.trim(), - email: ownerEmail, + email: organizationEmail, owner: { connect: { id: owner.id } }, - type: { connect: { name: invitation.invitedOrganizationType } }, + type: { connect: { name: dto.organizationType } }, plan: { connect: { name: 'trial' } }, }, }); @@ -505,6 +586,7 @@ export class OrganizationService { ? [invitation.inviterOrganizationId, targetOrganizationId] : [targetOrganizationId, invitation.inviterOrganizationId]; + // Same link row created at invite time; inviter never needs a separate connection request. await tx.organizationLink.upsert({ where: { organizationAId_organizationBId: { organizationAId: aId, organizationBId: bId } }, update: { status: LinkStatus.ACTIVE }, @@ -531,7 +613,7 @@ export class OrganizationService { return { success: true, data: { organizationId: organization }, - message: 'Invitation accepted. Organization trial has started and link is active.', + message: 'Invitation accepted. Organization trial has started and connection is active.', }; } @@ -593,11 +675,7 @@ export class OrganizationService { return `${appUrl}/accept-organization-invite?token=${encodeURIComponent(token)}`; } - private buildInviteUrlFromTokenHashPlaceholder(): null { - // Raw token cannot be reconstructed from hash, so pending links are preserved client-side after creation. - return null; - } - + /** Parses `requested_by:{orgId}` from OrganizationLink.sharedDataTypes while status is PENDING. */ private getRequesterOrganizationId(sharedDataTypes: unknown): string | null { if (!Array.isArray(sharedDataTypes)) return null; for (const v of sharedDataTypes) { diff --git a/backend/src/modules/staff/staff.controller.ts b/backend/src/modules/staff/staff.controller.ts index df4ba8d..e4732ea 100644 --- a/backend/src/modules/staff/staff.controller.ts +++ b/backend/src/modules/staff/staff.controller.ts @@ -55,6 +55,19 @@ export class StaffController { return this.staffService.invite(req.user.id, organizationId, dto); } + @Post('members/:membershipId/invitation-link') + @UseGuards(JwtAuthGuard) + @ApiOperation({ + summary: 'Regenerate and return invite link for a pending staff member', + }) + getInvitationLink( + @Req() req: { user: { id: string; organizationId?: string } }, + @Param('membershipId') membershipId: string, + ) { + const organizationId = this.staffService.getOrganizationIdFromUser(req.user); + return this.staffService.getInvitationLink(req.user.id, organizationId, membershipId); + } + @Patch('members/:membershipId') @UseGuards(JwtAuthGuard) @ApiOperation({ summary: 'Update staff member name and/or permissions' }) diff --git a/backend/src/modules/staff/staff.service.ts b/backend/src/modules/staff/staff.service.ts index 92a1fd5..2dcd3b2 100644 --- a/backend/src/modules/staff/staff.service.ts +++ b/backend/src/modules/staff/staff.service.ts @@ -224,6 +224,62 @@ export class StaffService { }; } + async getInvitationLink(userId: string, organizationId: string, membershipId: string) { + const actor = await this.getActorMembership(userId, organizationId); + if (!actor || !this.canEditStaff(actor)) { + throw new ForbiddenException('You cannot invite or manage staff'); + } + + const membership = await this.prisma.membership.findFirst({ + where: { id: membershipId, organizationId }, + include: { + user: { select: { email: true } }, + invitations: { orderBy: { createdAt: 'desc' }, take: 1 }, + }, + }); + + if (!membership) { + throw new NotFoundException('Member not found'); + } + if (membership.isOwner) { + throw new BadRequestException('Owner does not use an invitation link'); + } + if (membership.isActive) { + throw new BadRequestException('This member has already accepted their invitation'); + } + + const invitation = membership.invitations[0]; + if (!invitation) { + throw new BadRequestException('No invitation found for this member'); + } + if (invitation.acceptedAt) { + throw new BadRequestException('This invitation has already been accepted'); + } + if (invitation.revokedAt) { + throw new BadRequestException('This invitation is no longer valid'); + } + + const plainToken = this.generateInviteToken(); + const tokenHash = this.hashInviteToken(plainToken); + await this.prisma.staffInvitation.update({ + where: { id: invitation.id }, + data: { + tokenHash, + expiresAt: this.getInviteExpiryDate(), + }, + }); + + return { + success: true, + data: { + membershipId: membership.id, + invitationId: invitation.id, + email: membership.user.email, + invitationUrl: this.buildInviteUrl(plainToken), + }, + }; + } + async previewInvite(token: string) { const invitation = await this.findValidInvitation(token); const org = invitation.membership.organization; @@ -383,7 +439,8 @@ export class StaffService { if (m.isOwner || m.isActive) return 'ACTIVE'; const invitation = m.invitations[0]; if (!invitation) return 'EXPIRED'; - if (invitation.acceptedAt || invitation.revokedAt) return 'ACTIVE'; + if (invitation.acceptedAt) return 'ACTIVE'; + if (invitation.revokedAt) return 'EXPIRED'; return invitation.expiresAt.getTime() > Date.now() ? 'PENDING' : 'EXPIRED'; } diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..8b36792 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,7 @@ +# Copy to .env.local for local development (Next.js loads .env.local automatically). +# Do not commit .env.local — only this template is tracked in git. + +NEXT_PUBLIC_API_URL=http://localhost:3000/api +NEXT_PUBLIC_APP_NAME=DyoLink +# URL where users open the frontend (used for metadata, images, etc.) +NEXT_PUBLIC_APP_URL=http://localhost:3001 diff --git a/frontend/.gitignore b/frontend/.gitignore index 056cdb2..2bd9440 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -31,7 +31,9 @@ yarn-error.log* .pnpm-debug.log* # env files (can opt-in for committing if needed) -.env* +.env +.env.* +!.env.example # vercel .vercel diff --git a/frontend/package.json b/frontend/package.json index 2cf888b..f07681e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,7 +5,7 @@ "scripts": { "dev": "next dev -p 3001", "build": "next build", - "start": "next start -p 3000", + "start": "next start -p 3001", "lint": "next lint" }, "dependencies": { diff --git a/frontend/src/app/(dashboard)/appointments/page.tsx b/frontend/src/app/(dashboard)/appointments/page.tsx index 1f6a85b..45513c8 100644 --- a/frontend/src/app/(dashboard)/appointments/page.tsx +++ b/frontend/src/app/(dashboard)/appointments/page.tsx @@ -14,7 +14,8 @@ import { AppointmentScheduleGrid } from '@/components/ui/appointments/Appointmen import { AppointmentsPatientSearch } from '@/components/ui/appointments/AppointmentsPatientSearch'; import { AppointmentScheduleLegend } from '@/components/ui/appointments/AppointmentScheduleLegend'; import { ScheduleDayPicker } from '@/components/ui/common/ScheduleDayPicker'; -import { Toast } from '@/components/ui/common/Toast'; +import { ToastStack } from '@/components/ui/common/Toast'; +import { useToast } from '@/lib/hooks/useToast'; import type { AppointmentPurpose } from '@/types/appointment'; import { formatApiErrorMessage } from '@/lib/formatApiError'; import { compareLocalDayStart, getLocalDayIsoRange, startOfLocalDay } from '@/lib/appointmentTime'; @@ -33,7 +34,7 @@ export default function AppointmentsPage() { const [providers, setProviders] = useState([]); const [appointments, setAppointments] = useState([]); const [loadingSchedule, setLoadingSchedule] = useState(false); - const [scheduleError, setScheduleError] = useState(''); + const toast = useToast(); const [search, setSearch] = useState(''); const [patients, setPatients] = useState([]); @@ -50,10 +51,8 @@ export default function AppointmentsPage() { const [bookingProviderName, setBookingProviderName] = useState(''); const [editingAppointmentId, setEditingAppointmentId] = useState(null); const [savingAppointment, setSavingAppointment] = useState(false); + const [deletingAppointment, setDeletingAppointment] = useState(false); - const [toastError, setToastError] = useState(''); - const [toastSuccess, setToastSuccess] = useState(''); - const [toastInfo, setToastInfo] = useState(''); const canManageAppointments = canEditAppointments(currentOrganization); const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT'); @@ -84,7 +83,7 @@ export default function AppointmentsPage() { } const gen = ++scheduleLoadGen.current; setLoadingSchedule(true); - setScheduleError(''); + toast.setError(''); try { const range = getLocalDayIsoRange(scheduleDate); const [pRes, aRes] = await Promise.all([ @@ -100,7 +99,7 @@ export default function AppointmentsPage() { if (gen !== scheduleLoadGen.current) { return; } - setScheduleError(formatApiErrorMessage(err, 'Failed to load schedule.')); + toast.showError(formatApiErrorMessage(err, 'Failed to load schedule.')); } finally { if (gen === scheduleLoadGen.current) { setLoadingSchedule(false); @@ -143,21 +142,20 @@ export default function AppointmentsPage() { async function handleCreatePatient() { setSavingPatient(true); - setToastError(''); - setToastSuccess(''); + toast.setError(''); try { const response = await patientsApi.create(patientForm); setIsCreateOpen(false); setPatientForm(EMPTY_PATIENT_FORM); await loadPatientsSearch(search); setSelectedPatient(response.data); - setToastSuccess(`Patient ${response.data.firstName} ${response.data.lastName} was saved.`); + toast.showSuccess(`Patient ${response.data.firstName} ${response.data.lastName} was saved.`); } catch (err: unknown) { const message = err && typeof err === 'object' && 'message' in err ? String((err as { message: unknown }).message) : 'Failed to save patient.'; - setToastError(message); + toast.showError(message); } finally { setSavingPatient(false); } @@ -165,15 +163,11 @@ export default function AppointmentsPage() { function handleSlotClick(hour: number, providerUserId: string, providerName: string) { if (isViewingPastDay) { - setToastSuccess(''); - setToastError(''); - setToastInfo('Past appointments are view-only.'); + toast.showInfo('Past appointments are view-only.'); return; } if (!selectedPatient) { - setToastSuccess(''); - setToastError(''); - setToastInfo('Select a patient before booking.'); + toast.showInfo('Select a patient before booking.'); return; } setBookingHour(hour); @@ -185,9 +179,7 @@ export default function AppointmentsPage() { function handleAppointmentClick(appointment: AppointmentRecord) { if (isViewingPastDay) { - setToastSuccess(''); - setToastError(''); - setToastInfo('Past appointments are view-only.'); + toast.showInfo('Past appointments are view-only.'); return; } const provider = providers.find((p) => p.userId === appointment.providerUserId); @@ -206,9 +198,7 @@ export default function AppointmentsPage() { purpose: AppointmentPurpose; }) { setSavingAppointment(true); - setToastError(''); - setToastSuccess(''); - setToastInfo(''); + toast.setError(''); try { if (activeEditingAppointment) { await appointmentsApi.update(activeEditingAppointment.id, payload); @@ -217,7 +207,7 @@ export default function AppointmentsPage() { } setBookingOpen(false); setEditingAppointmentId(null); - setToastSuccess(activeEditingAppointment ? 'Appointment updated.' : 'Appointment saved.'); + toast.showSuccess(activeEditingAppointment ? 'Appointment updated.' : 'Appointment saved.'); await loadSchedule(); } catch (err: unknown) { const message = @@ -226,67 +216,51 @@ export default function AppointmentsPage() { : activeEditingAppointment ? 'Could not update appointment.' : 'Could not save appointment.'; - setToastError(message); + toast.showError(message); } finally { setSavingAppointment(false); } } - async function handleDeleteAppointment(id: string) { + async function handleDeleteEditingAppointment() { + if (!activeEditingAppointment) { + return; + } if (!window.confirm('Remove this appointment?')) { return; } - setToastError(''); - setToastSuccess(''); - setToastInfo(''); + setDeletingAppointment(true); + toast.setError(''); try { - await appointmentsApi.remove(id); - setToastSuccess('Appointment removed.'); + await appointmentsApi.remove(activeEditingAppointment.id); + setBookingOpen(false); + setEditingAppointmentId(null); + toast.showSuccess('Appointment removed.'); await loadSchedule(); } catch (err: unknown) { const message = err && typeof err === 'object' && 'message' in err ? String((err as { message: unknown }).message) : 'Could not delete appointment.'; - setToastError(message); + toast.showError(message); + } finally { + setDeletingAppointment(false); } } - useEffect(() => { - if (!toastSuccess) { - return; - } - const id = setTimeout(() => setToastSuccess(''), 3200); - return () => clearTimeout(id); - }, [toastSuccess]); - - useEffect(() => { - if (!toastError) { - return; - } - const id = setTimeout(() => setToastError(''), 4000); - return () => clearTimeout(id); - }, [toastError]); - - useEffect(() => { - if (!toastInfo) { - return; - } - const id = setTimeout(() => setToastInfo(''), 4000); - return () => clearTimeout(id); - }, [toastInfo]); - return ( -
+
+
+

Appointments

+

+ Search a patient, pick a date, then click a time slot under a provider to book. +

+
+ + +
-
-

Appointments

-

- Search a patient, pick a date, then click a time slot under a provider to book. -

-
- void handleDeleteAppointment(id)} onSlotClick={(hour, uid, name) => handleSlotClick(hour, uid, name)} onAppointmentClick={(apt) => handleAppointmentClick(apt)} /> @@ -346,6 +318,9 @@ export default function AppointmentsPage() { }} onSubmit={handleSaveAppointment} loading={savingAppointment} + canDelete={canManageAppointments && !isViewingPastDay && !!activeEditingAppointment} + onDelete={() => void handleDeleteEditingAppointment()} + deleting={deletingAppointment} /> {isCreateOpen && ( @@ -361,16 +336,6 @@ export default function AppointmentsPage() {
)} - {(scheduleError || toastError || toastSuccess || toastInfo) && ( -
-
- {scheduleError && {scheduleError}} - {toastError && {toastError}} - {toastInfo && {toastInfo}} - {toastSuccess && {toastSuccess}} -
-
- )}
); } diff --git a/frontend/src/app/(dashboard)/organizations/page.tsx b/frontend/src/app/(dashboard)/organizations/page.tsx index 0d8390a..d581d28 100644 --- a/frontend/src/app/(dashboard)/organizations/page.tsx +++ b/frontend/src/app/(dashboard)/organizations/page.tsx @@ -1,66 +1,49 @@ -'use client'; +'use client'; import { useEffect, useState } from 'react'; -import { Check, Copy, Link2, Trash2, X } from 'lucide-react'; +import { useToast } from '@/lib/hooks/useToast'; +import { Check, Trash2, UserPlus, X } from 'lucide-react'; import { useAuth } from '@/lib/hooks/useAuth'; +import { useOrganizationInviteLinkCopy } from '@/lib/hooks/useOrganizationInviteLinkCopy'; import { organizationApi, type CounterpartItemDto, type CounterpartSearchResultDto, type OrganizationInvitationHistoryItemDto, } from '@/lib/api/organization'; +import { invitationTargetFromConnectionRow } from '@/components/invitations/organizationInviteLinks'; +import { CopyInvitationLinkButton } from '@/components/ui/organizations/CopyInvitationLinkButton'; +import { InvitationHistoryDialog } from '@/components/ui/organizations/InvitationHistoryDialog'; import { Button } from '@/components/ui/common/Button'; -import { Badge, organizationLinkStatusVariant } from '@/components/ui/common/Badge'; +import { Badge, organizationConnectionStatusVariant } from '@/components/ui/common/Badge'; import { Input } from '@/components/ui/common/Input'; import { SearchBar } from '@/components/ui/common/SearchBar'; import { Table } from '@/components/ui/common/Table'; +import { ToastStack } from '@/components/ui/common/Toast'; import type { ApiError } from '@/types/api'; -type StoredInviteLink = { - invitationId: string; - ownerEmail: string; - invitationUrl: string; -}; - -function inviteLinksStorageKey(orgId: string): string { - return `counterpartInviteLinks:${orgId}`; -} - -function readStoredInviteLinks(orgId: string): Record { - if (typeof window === 'undefined') return {}; - try { - const raw = window.localStorage.getItem(inviteLinksStorageKey(orgId)); - if (!raw) return {}; - const parsed = JSON.parse(raw) as Record; - return parsed && typeof parsed === 'object' ? parsed : {}; - } catch { - return {}; - } -} - -function writeStoredInviteLinks(orgId: string, links: Record) { - if (typeof window === 'undefined') return; - window.localStorage.setItem(inviteLinksStorageKey(orgId), JSON.stringify(links)); -} - function formatOrganizationStatusLabel(status: string): string { if (!status) return status; const lower = status.toLowerCase(); return lower.charAt(0).toUpperCase() + lower.slice(1); } -function formatLinkStatusLabel(status: CounterpartItemDto['status']): string { - if (status === 'PENDING') return 'Link request pending'; - if (status === 'ACTIVE') return 'Linked'; - if (status === 'REJECTED') return 'Link request rejected'; - return formatOrganizationStatusLabel(status); -} - -function formatInvitationStatusLabel(status: OrganizationInvitationHistoryItemDto['status']): string { - if (status === 'PENDING') return 'Invitation pending'; - if (status === 'ACTIVE') return 'Invitation Accepted'; - if (status === 'REJECTED') return 'Invitation rejected'; - return formatOrganizationStatusLabel(status); +function formatConnectionStatusLabel( + row: CounterpartItemDto, + currentOrganizationId: string, +): string { + if (row.status === 'PENDING') { + if ( + row.pendingInvitationId && + row.requestedByOrganizationId === currentOrganizationId + ) { + return 'Invitation pending'; + } + return 'Connection request pending'; + } + if (row.status === 'ACTIVE') return 'Connected'; + if (row.status === 'REJECTED') return 'Connection request declined'; + return formatOrganizationStatusLabel(row.status); } function formatApiMessage(err: unknown): string { @@ -73,7 +56,7 @@ function formatApiMessage(err: unknown): string { function formatTableDate(value: string): string { const d = new Date(value); - if (Number.isNaN(d.getTime())) return '—'; + if (Number.isNaN(d.getTime())) return '\u2014'; return d.toLocaleDateString(); } @@ -82,27 +65,32 @@ type TableMode = 'existing' | 'search'; export default function OrganizationsPage() { const { currentOrganization } = useAuth(); const [loading, setLoading] = useState(true); - const [error, setError] = useState(''); - const [success, setSuccess] = useState(''); + const toast = useToast(); const [query, setQuery] = useState(''); const [mode, setMode] = useState('existing'); const [searching, setSearching] = useState(false); const [searchResults, setSearchResults] = useState([]); - const [requestLinkRowId, setRequestLinkRowId] = useState(null); - const [deleteLinkRowId, setDeleteLinkRowId] = useState(null); + const [pendingConnectionRowId, setPendingConnectionRowId] = useState(null); + const [deleteConnectionRowId, setDeleteConnectionRowId] = useState(null); const [items, setItems] = useState([]); const [manualOrganizationName, setManualOrganizationName] = useState(''); const [manualOwnerEmail, setManualOwnerEmail] = useState(''); const [inviteLoading, setInviteLoading] = useState(false); - const [copiedId, setCopiedId] = useState(null); - const [pendingInviteLinks, setPendingInviteLinks] = useState>({}); const [showInviteForm, setShowInviteForm] = useState(false); const [historyOpen, setHistoryOpen] = useState(false); const [historyLoading, setHistoryLoading] = useState(false); const [historyItems, setHistoryItems] = useState([]); + const { + copiedId, + copyingInvitationId, + storeInviteLink, + copyInvitationLink, + pruneAcceptedLinks, + } = useOrganizationInviteLinkCopy(currentOrganization?.id); + const counterpartLabel = currentOrganization?.type === 'LAB' ? 'Clinic' : 'Lab'; const tabLabel = currentOrganization?.type === 'LAB' ? 'Clinics' : 'Labs'; @@ -110,32 +98,21 @@ export default function OrganizationsPage() { async function loadList() { setLoading(true); - setError(''); + toast.setError(''); try { const res = await organizationApi.list(); setItems(res.data.items); } catch (e) { - setError(formatApiMessage(e)); + toast.showError(formatApiMessage(e)); } finally { setLoading(false); } } - useEffect(() => { - if (!currentOrganization?.id) return; - setPendingInviteLinks(readStoredInviteLinks(currentOrganization.id)); - }, [currentOrganization?.id]); - useEffect(() => { void loadList(); }, []); - useEffect(() => { - if (!success) return; - const t = setTimeout(() => setSuccess(''), 4000); - return () => clearTimeout(t); - }, [success]); - async function runSearch() { const q = query.trim(); if (!q) { @@ -146,58 +123,47 @@ export default function OrganizationsPage() { } setSearching(true); - setError(''); + toast.setError(''); setMode('search'); setShowInviteForm(false); try { const res = await organizationApi.search(q); setSearchResults(res.data); } catch (e) { - setError(formatApiMessage(e)); + toast.showError(formatApiMessage(e)); setSearchResults([]); } finally { setSearching(false); } } - async function submitRequestLink(targetOrganizationId: string) { - setRequestLinkRowId(targetOrganizationId); - setError(''); + async function submitConnectionRequest(targetOrganizationId: string) { + setPendingConnectionRowId(targetOrganizationId); + toast.setError(''); try { - await organizationApi.createLink(targetOrganizationId); - setSuccess(`${counterpartLabel} link request sent`); + await organizationApi.createConnectionRequest(targetOrganizationId); + toast.showSuccess(`${counterpartLabel} connection request sent.`); setSearchResults([]); setQuery(''); setMode('existing'); await loadList(); } catch (e) { - setError(formatApiMessage(e)); + toast.showError(formatApiMessage(e)); } finally { - setRequestLinkRowId(null); + setPendingConnectionRowId(null); } } async function sendInvite() { setInviteLoading(true); - setError(''); + toast.setError(''); try { const res = await organizationApi.invite({ organizationName: manualOrganizationName.trim(), ownerEmail: manualOwnerEmail.trim(), }); - if (currentOrganization?.id) { - const nextLinks = { - ...pendingInviteLinks, - [res.data.invitationId]: { - invitationId: res.data.invitationId, - ownerEmail: manualOwnerEmail.trim().toLowerCase(), - invitationUrl: res.data.invitationUrl, - }, - }; - setPendingInviteLinks(nextLinks); - writeStoredInviteLinks(currentOrganization.id, nextLinks); - } - setSuccess(`Invitation link created for ${manualOwnerEmail.trim()}`); + storeInviteLink(res.data.invitationId, manualOwnerEmail, res.data.invitationUrl); + toast.showSuccess(`Invitation link created for ${manualOwnerEmail.trim()}`); setManualOrganizationName(''); setManualOwnerEmail(''); setShowInviteForm(false); @@ -206,80 +172,99 @@ export default function OrganizationsPage() { setSearchResults([]); await loadList(); } catch (e) { - setError(formatApiMessage(e)); + toast.showError(formatApiMessage(e)); } finally { setInviteLoading(false); } } + async function loadInvitationHistory() { + const res = await organizationApi.listInvitations(); + setHistoryItems(res.data.items); + pruneAcceptedLinks(res.data.items); + return res.data.items; + } + async function openInvitationHistory() { setHistoryOpen(true); setHistoryLoading(true); - setError(''); + toast.clear(); try { - const res = await organizationApi.listInvitations(); - setHistoryItems(res.data.items); + await loadInvitationHistory(); } catch (e) { - setError(formatApiMessage(e)); + toast.showError(formatApiMessage(e)); } finally { setHistoryLoading(false); } } - async function respondToPendingLink(linkId: string, action: 'ACCEPT' | 'REJECT') { - setRequestLinkRowId(linkId); - setError(''); + async function handleHistoryCopy(invitation: OrganizationInvitationHistoryItemDto) { + toast.setError(''); try { - await organizationApi.respondLink(linkId, action); - setSuccess(action === 'ACCEPT' ? 'Link request accepted' : 'Link request rejected'); - await loadList(); + await copyInvitationLink(invitation, { + onRegenerated: async () => { + await loadInvitationHistory(); + }, + }); + toast.showSuccess('Invitation link copied to clipboard.'); } catch (e) { - setError(formatApiMessage(e)); - } finally { - setRequestLinkRowId(null); + toast.showError(formatApiMessage(e)); } } - async function deleteLinkedOrganization(linkId: string) { - setDeleteLinkRowId(linkId); - setError(''); + async function handleCopyInvitationFromRow(row: CounterpartItemDto) { + const target = invitationTargetFromConnectionRow(row, currentOrganization!.id); + if (!target) return; + toast.setError(''); try { - await organizationApi.deleteLink(linkId); - setSuccess('Linked organization removed'); - await loadList(); + await copyInvitationLink( + { + id: target.id, + organizationName: row.organizationName, + ownerEmail: target.ownerEmail, + status: target.status, + createdAt: row.createdAt, + acceptedAt: target.acceptedAt, + }, + { + onRegenerated: async () => { + await loadList(); + }, + }, + ); + toast.showSuccess('Invitation link copied to clipboard.'); } catch (e) { - setError(formatApiMessage(e)); - } finally { - setDeleteLinkRowId(null); + toast.showError(formatApiMessage(e)); } } - async function copyInvitationLink(invitationId: string) { - setError(''); + async function respondToPendingConnection(connectionId: string, action: 'ACCEPT' | 'REJECT') { + setPendingConnectionRowId(connectionId); + toast.setError(''); try { - let invitationUrl = pendingInviteLinks[invitationId]?.invitationUrl; - if (!invitationUrl) { - const res = await organizationApi.getInvitationLink(invitationId); - invitationUrl = res.data.invitationUrl; - if (currentOrganization?.id) { - const nextLinks = { - ...pendingInviteLinks, - [invitationId]: { - invitationId, - ownerEmail: - historyItems.find((item) => item.id === invitationId)?.ownerEmail?.toLowerCase() ?? '', - invitationUrl, - }, - }; - setPendingInviteLinks(nextLinks); - writeStoredInviteLinks(currentOrganization.id, nextLinks); - } - } - await navigator.clipboard.writeText(invitationUrl); - setCopiedId(invitationId); - setTimeout(() => setCopiedId(null), 1500); - } catch { - setError('Could not copy invitation link'); + await organizationApi.respondToConnectionRequest(connectionId, action); + toast.showSuccess( + action === 'ACCEPT' ? 'Connection request accepted.' : 'Connection request declined.', + ); + await loadList(); + } catch (e) { + toast.showError(formatApiMessage(e)); + } finally { + setPendingConnectionRowId(null); + } + } + + async function deleteConnection(connectionId: string) { + setDeleteConnectionRowId(connectionId); + toast.setError(''); + try { + await organizationApi.deleteConnection(connectionId); + toast.showSuccess('Connection removed.'); + await loadList(); + } catch (e) { + toast.showError(formatApiMessage(e)); + } finally { + setDeleteConnectionRowId(null); } } @@ -300,7 +285,8 @@ export default function OrganizationsPage() {

{tabLabel}

- Search organizations and send link requests or invitation links in one place. + Search organizations, send connection requests to existing accounts, or invitation + links when they are not on DyoLink yet.

- {error && ( -
- {error} -
- )} - {success && ( -
- {success} -
- )} + {!historyOpen && } - No organizations linked or pending yet. Use search to find and connect. + No connections yet. Search to send a connection request or an invitation link. ) : ( @@ -388,6 +365,10 @@ export default function OrganizationsPage() { row.status === 'PENDING' && row.requestedByOrganizationId !== null && row.requestedByOrganizationId !== currentOrganization.id; + const invitationTarget = invitationTargetFromConnectionRow( + row, + currentOrganization.id, + ); return ( @@ -399,31 +380,39 @@ export default function OrganizationsPage() { {formatTableDate(row.createdAt)} - - {formatLinkStatusLabel(row.status)} + + {formatConnectionStatusLabel(row, currentOrganization.id)}
+ {invitationTarget && ( + void handleCopyInvitationFromRow(row)} + /> + )} {canRespond && ( <> @@ -433,10 +422,10 @@ export default function OrganizationsPage() { @@ -460,12 +449,14 @@ export default function OrganizationsPage() { @@ -516,87 +507,16 @@ export default function OrganizationsPage() { } /> - {historyOpen && ( -
-
-
-

Invitation History

- -
- - {historyLoading ? ( -

Loading invitation history...

- ) : historyItems.length === 0 ? ( -

No invitations yet.

- ) : ( - - - - - - - - } - body={ - <> - {historyItems.map((inv) => ( - - - - - - - - ))} - - } - /> - )} - - - )} + setHistoryOpen(false)} + loading={historyLoading} + items={historyItems} + copiedId={copiedId} + copyingInvitationId={copyingInvitationId} + onCopy={(invitation) => void handleHistoryCopy(invitation)} + toastMessages={toast.messages} + /> ); } diff --git a/frontend/src/app/(dashboard)/settings/organizations/page.tsx b/frontend/src/app/(dashboard)/settings/organizations/page.tsx index dd5ad9c..ccbdb3b 100644 --- a/frontend/src/app/(dashboard)/settings/organizations/page.tsx +++ b/frontend/src/app/(dashboard)/settings/organizations/page.tsx @@ -1,7 +1,7 @@ 'use client'; import Link from 'next/link'; -import { OrganizationSelectorContent } from '@/components/ui/organization/OrganizationSelectorContent'; +import { OrganizationSelectorContent } from '@/components/ui/organizations/OrganizationSelectorContent'; export default function DashboardOrganizationsSettingsPage() { return ( diff --git a/frontend/src/app/(dashboard)/staff/page.tsx b/frontend/src/app/(dashboard)/staff/page.tsx index 71adcf0..23c60fe 100644 --- a/frontend/src/app/(dashboard)/staff/page.tsx +++ b/frontend/src/app/(dashboard)/staff/page.tsx @@ -17,6 +17,7 @@ import { type FeaturePermState, } from '../../../components/staff/staff-permission-form'; import { Pencil, Trash2, Copy, Check, X } from 'lucide-react'; +import { DialogCloseButton } from '@/components/ui/common/DialogCloseButton'; import { useAuth } from '@/lib/hooks/useAuth'; import { staffApi, type StaffMemberDto } from '@/lib/api/staff'; import { Button } from '@/components/ui/common/Button'; @@ -61,6 +62,10 @@ function formatApiMessage(err: unknown): string { return 'Something went wrong'; } +function canShareStaffInviteLink(member: StaffMemberDto): boolean { + return !member.isOwner && member.invitationStatus !== 'ACTIVE'; +} + function PermissionGrid({ state, onChange, @@ -140,6 +145,7 @@ export default function StaffPage() { const [invitePerms, setInvitePerms] = useState(() => emptyFeaturePermissionState()); const [inviteLoading, setInviteLoading] = useState(false); const [copiedInviteMembershipId, setCopiedInviteMembershipId] = useState(null); + const [copyingInviteMembershipId, setCopyingInviteMembershipId] = useState(null); const [lastInviteInfo, setLastInviteInfo] = useState<{ membershipId: string; name: string; @@ -221,6 +227,42 @@ export default function StaffPage() { return () => clearTimeout(t); }, [success]); + async function copyStaffInviteLink(member: StaffMemberDto) { + if (!canShareStaffInviteLink(member)) return; + + setCopyingInviteMembershipId(member.id); + setError(''); + try { + let invitationUrl = pendingInviteLinks[member.id]?.invitationUrl; + if (!invitationUrl || member.invitationStatus === 'EXPIRED') { + const res = await staffApi.getInvitationLink(member.id); + invitationUrl = res.data.invitationUrl; + if (currentOrganization?.id) { + const nextLinks = { + ...pendingInviteLinks, + [member.id]: { + membershipId: member.id, + email: member.email, + invitationUrl, + }, + }; + setPendingInviteLinks(nextLinks); + writeStoredInviteLinks(currentOrganization.id, nextLinks); + } + } + await navigator.clipboard.writeText(invitationUrl); + setCopiedInviteMembershipId(member.id); + setTimeout(() => setCopiedInviteMembershipId(null), 1500); + if (member.invitationStatus === 'EXPIRED') { + await load(); + } + } catch (e) { + setError(formatApiMessage(e)); + } finally { + setCopyingInviteMembershipId(null); + } + } + async function submitInvite() { setInviteLoading(true); setError(''); @@ -388,34 +430,60 @@ export default function StaffPage() { ? ' Invitation is pending until they open the link, set a password, and log in.' : ' Invitation was accepted immediately.'}

- {lastInviteInfo.invitationUrl && ( + {lastInviteInfo.invitationStatus === 'PENDING' && (

Invite link

-
- + {lastInviteInfo.invitationUrl && ( + {lastInviteInfo.invitationUrl} - -
+ })(); + }} + > + {copiedInviteMembershipId === lastInviteInfo.membershipId ? 'Copied' : 'Copy link'} +

- Share this link manually via SMS or email. They must set password first. + Share this link manually via SMS or email. A new link is generated if the previous one expired or was lost.

)} @@ -470,21 +538,14 @@ export default function StaffPage() {
- Organization - - Owner email - - Date - - Status - - Action -
{inv.organizationName}{inv.ownerEmail} - {formatTableDate(inv.createdAt)} - - - {formatInvitationStatusLabel(inv.status)} - - - {inv.status === 'PENDING' ? ( - - ) : ( - - )} -
{!m.isOwner && (
- {m.invitationStatus === 'PENDING' && pendingInviteLinks[m.id]?.invitationUrl && ( + {canShareStaffInviteLink(m) && ( -
- )} -

- Already have access? Go to login -

- - )} +
+
+ + DyoLink + +

+ Accept organization invitation +

+

+ Already have an account?{' '} + + Sign in + +

+
+ +
+
+ {loading ? ( +

Loading invitation...

+ ) : ( + <> + {inviteInfo && ( +
+

+ Invited by:{' '} + {inviteInfo.inviterOrganizationName} +

+
+ )} + + {inviteInfo?.status !== 'ACCEPTED' && ( + + )} + + {error && ( +
+

{error}

+
+ )} + {success && ( +
+ {success} +
+ )} + + {inviteInfo?.status !== 'ACCEPTED' && ( +
+ {step === 1 && ( + <> + } + /> + } + /> + } + /> + } + /> + + + )} + + {step === 2 && ( + <> + } + errors={errors as FieldErrors} + organizationType={organizationType} + setValue={setValue as unknown as UseFormSetValue} + /> +
+ + +
+ + )} + + )} + + )} +
); diff --git a/frontend/src/app/(public)/register/page.tsx b/frontend/src/app/(public)/register/page.tsx index 1d32b65..deda726 100644 --- a/frontend/src/app/(public)/register/page.tsx +++ b/frontend/src/app/(public)/register/page.tsx @@ -5,8 +5,10 @@ import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import * as z from 'zod'; import Link from 'next/link'; -import { Building2, Mail, Lock, User, ChevronRight } from 'lucide-react'; +import { Mail, Lock, User } from 'lucide-react'; import { useAuth } from '@/lib/hooks/useAuth'; +import { OrganizationDetailsFields } from '@/components/ui/auth/OrganizationDetailsFields'; +import { RegistrationProgressSteps } from '@/components/ui/auth/RegistrationProgressSteps'; import { Button } from '@/components/ui/common/Button'; import { Input } from '@/components/ui/common/Input'; const registerSchema = z.object({ @@ -89,31 +91,7 @@ export default function RegisterPage() {
- {/* Progress Steps */} -
-
-
-
= 1 ? 'bg-primary text-primary-contrast' : 'bg-background-secondary text-text-secondary border border-border'}`}> - 1 -
-
= 1 ? 'text-primary' : 'text-text-muted' - }`}> - Account -
-
- -
-
= 2 ? 'bg-primary text-primary-contrast' : 'bg-background-secondary text-text-secondary border border-border'}`}> - - 2 -
-
= 2 ? 'text-primary' : 'text-text-muted' - }`}> - Organization -
-
-
-
+ {/* Trial Info Banner */}

Your trial @@ -177,57 +155,12 @@ export default function RegisterPage() { )} {step === 2 && ( <> - } + - } - /> -
- - -
- - -
- {errors.organizationType && ( -

{errors.organizationType.message}

- )} -
{error && (

{error}

diff --git a/frontend/src/app/(public)/select-organization/page.tsx b/frontend/src/app/(public)/select-organization/page.tsx index 5307a43..192288c 100644 --- a/frontend/src/app/(public)/select-organization/page.tsx +++ b/frontend/src/app/(public)/select-organization/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { OrganizationSelectorContent } from '@/components/ui/organization/OrganizationSelectorContent'; +import { OrganizationSelectorContent } from '@/components/ui/organizations/OrganizationSelectorContent'; export default function SelectOrganizationPage() { return ( diff --git a/frontend/src/components/invitations/organizationInviteLinks.ts b/frontend/src/components/invitations/organizationInviteLinks.ts new file mode 100644 index 0000000..4cbe038 --- /dev/null +++ b/frontend/src/components/invitations/organizationInviteLinks.ts @@ -0,0 +1,68 @@ +import type { + CounterpartItemDto, + OrganizationInvitationHistoryItemDto, +} from '@/lib/api/organization'; + +export type InvitationLinkTarget = Pick< + OrganizationInvitationHistoryItemDto, + 'id' | 'ownerEmail' | 'status' | 'acceptedAt' +>; + +/** Cached after POST /organizations/invite because only tokenHash is persisted server-side. */ +export type StoredOrganizationInviteLink = { + invitationId: string; + ownerEmail: string; + invitationUrl: string; +}; + +export function organizationInviteLinksStorageKey(orgId: string): string { + return `counterpartInviteLinks:${orgId}`; +} + +export function readOrganizationInviteLinks( + orgId: string, +): Record { + if (typeof window === 'undefined') return {}; + try { + const raw = window.localStorage.getItem(organizationInviteLinksStorageKey(orgId)); + if (!raw) return {}; + const parsed = JSON.parse(raw) as Record; + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch { + return {}; + } +} + +export function writeOrganizationInviteLinks( + orgId: string, + links: Record, +): void { + if (typeof window === 'undefined') return; + window.localStorage.setItem(organizationInviteLinksStorageKey(orgId), JSON.stringify(links)); +} + +/** Show copy/regenerate only until the invitee accepts (first login / setup). */ +export function canShareOrganizationInviteLink( + invitation: Pick, +): boolean { + if (invitation.acceptedAt) return false; + return invitation.status === 'PENDING' || invitation.status === 'EXPIRED'; +} + +/** + * Maps a connections-list row to copy/regenerate UI when it was created by the invitation flow. + * Plain invite URLs are not stored in the DB; use localStorage (storeInviteLink) or POST …/link. + */ +export function invitationTargetFromConnectionRow( + row: CounterpartItemDto, + currentOrganizationId: string, +): InvitationLinkTarget | null { + if (!row.pendingInvitationId) return null; + if (row.requestedByOrganizationId !== currentOrganizationId) return null; + return { + id: row.pendingInvitationId, + ownerEmail: row.ownerEmail, + status: row.invitationStatus ?? 'PENDING', + acceptedAt: null, + }; +} diff --git a/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx b/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx index 770f753..08d01a5 100644 --- a/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx +++ b/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx @@ -1,8 +1,8 @@ 'use client'; import { useEffect, useState } from 'react'; -import { X } from 'lucide-react'; import { Button } from '@/components/ui/common/Button'; +import { DialogCloseButton } from '@/components/ui/common/DialogCloseButton'; import { Dropdown } from '@/components/ui/common/Dropdown'; import type { AppointmentPurpose, AppointmentRecord } from '@/types/appointment'; import { APPOINTMENT_PURPOSE_LABEL } from '@/components/ui/appointments/appointmentPurposeStyles'; @@ -31,6 +31,9 @@ interface AppointmentBookingModalProps { }) => Promise; editingAppointment?: AppointmentRecord | null; loading?: boolean; + canDelete?: boolean; + onDelete?: () => void | Promise; + deleting?: boolean; } export function AppointmentBookingModal({ @@ -44,6 +47,9 @@ export function AppointmentBookingModal({ onSubmit, editingAppointment = null, loading = false, + canDelete = false, + onDelete, + deleting = false, }: AppointmentBookingModalProps) { const [startTime, setStartTime] = useState('09:00'); const [endTime, setEndTime] = useState('10:00'); @@ -161,14 +167,7 @@ export function AppointmentBookingModal({

{editingAppointment ? 'Edit appointment' : 'New appointment'}

- +

@@ -234,13 +233,34 @@ export function AppointmentBookingModal({ {error &&

{error}

} -
- - +
+ {editingAppointment && canDelete && onDelete ? ( + + ) : ( + + )} +
+ + +

diff --git a/frontend/src/components/ui/appointments/AppointmentOverlapPopover.tsx b/frontend/src/components/ui/appointments/AppointmentOverlapPopover.tsx new file mode 100644 index 0000000..01d7ec5 --- /dev/null +++ b/frontend/src/components/ui/appointments/AppointmentOverlapPopover.tsx @@ -0,0 +1,110 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import { DialogCloseButton } from '@/components/ui/common/DialogCloseButton'; +import { + APPOINTMENT_PURPOSE_LABEL, + purposeStyle, +} from '@/components/ui/appointments/appointmentPurposeStyles'; +import type { AppointmentRecord } from '@/types/appointment'; + +type AppointmentOverlapPopoverProps = { + appointments: AppointmentRecord[]; + anchorRect: DOMRect; + onSelect: (appointment: AppointmentRecord) => void; + onClose: () => void; +}; + +function formatTimeRange(apt: AppointmentRecord): string { + const start = new Date(apt.startAt); + const end = new Date(apt.endAt); + const opts: Intl.DateTimeFormatOptions = { hour: 'numeric', minute: '2-digit' }; + return `${start.toLocaleTimeString(undefined, opts)} – ${end.toLocaleTimeString(undefined, opts)}`; +} + +export function AppointmentOverlapPopover({ + appointments, + anchorRect, + onSelect, + onClose, +}: AppointmentOverlapPopoverProps) { + const panelRef = useRef(null); + + useEffect(() => { + function onPointerDown(event: MouseEvent) { + if (!panelRef.current?.contains(event.target as Node)) { + onClose(); + } + } + function onKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') { + onClose(); + } + } + document.addEventListener('mousedown', onPointerDown); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('mousedown', onPointerDown); + document.removeEventListener('keydown', onKeyDown); + }; + }, [onClose]); + + const sorted = [...appointments].sort( + (a, b) => new Date(a.startAt).getTime() - new Date(b.startAt).getTime(), + ); + + const viewportPadding = 12; + const panelWidth = Math.min(320, window.innerWidth - viewportPadding * 2); + let top = anchorRect.bottom + 8; + let left = anchorRect.left + anchorRect.width / 2 - panelWidth / 2; + left = Math.max(viewportPadding, Math.min(left, window.innerWidth - panelWidth - viewportPadding)); + const estimatedHeight = 56 + sorted.length * 52; + if (top + estimatedHeight > window.innerHeight - viewportPadding) { + top = Math.max(viewportPadding, anchorRect.top - estimatedHeight - 8); + } + + return ( +
+
+
+

+ Overlapping appointments ({sorted.length}) +

+ +
+
    + {sorted.map((apt) => { + const purpose = apt.purpose as keyof typeof APPOINTMENT_PURPOSE_LABEL; + return ( +
  • + +
  • + ); + })} +
+
+
+ ); +} diff --git a/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx b/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx index acb47a4..13f2912 100644 --- a/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx +++ b/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx @@ -1,12 +1,15 @@ 'use client'; -import { Trash2 } from 'lucide-react'; +import { useMemo, useState } from 'react'; import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment'; import { formatHourLabel } from '@/lib/appointmentTime'; import { - purposeDeleteIconClass, - purposeStyle, -} from '@/components/ui/appointments/appointmentPurposeStyles'; + computeAppointmentLaneLayouts, + findOverlapCluster, + lanePositionStyles, +} from '@/lib/appointmentOverlapLayout'; +import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles'; +import { AppointmentOverlapPopover } from '@/components/ui/appointments/AppointmentOverlapPopover'; const HOUR_PX = 40; const HOURS = Array.from({ length: 24 }, (_, i) => i); @@ -27,13 +30,37 @@ function layoutBlock(apt: AppointmentRecord, day: Date): { top: string; height: return { top: `${top}%`, height: `${height}%` }; } +function appointmentDurationMinutes(apt: AppointmentRecord): number { + const start = new Date(apt.startAt).getTime(); + const end = new Date(apt.endAt).getTime(); + return Math.max(0, Math.round((end - start) / 60_000)); +} + +function appointmentBannerHeightPx(durationMin: number): number { + return (durationMin / (24 * 60)) * HOURS.length * HOUR_PX; +} + +function shortBannerNameClass(durationMin: number): string { + const heightPx = appointmentBannerHeightPx(durationMin); + if (heightPx < 18) { + return 'text-[8px] leading-none'; + } + if (durationMin < 60) { + return 'text-[9px] leading-none'; + } + return 'text-[11px] leading-tight'; +} + +type OverlapPopoverState = { + appointments: AppointmentRecord[]; + anchorRect: DOMRect; +}; + interface AppointmentScheduleGridProps { day: Date; providers: AppointmentColumnProvider[]; appointments: AppointmentRecord[]; canBook: boolean; - canDelete?: boolean; - onDeleteAppointment?: (id: string) => void; onSlotClick: (hour: number, providerUserId: string, providerName: string) => void; onAppointmentClick?: (appointment: AppointmentRecord) => void; } @@ -43,12 +70,36 @@ export function AppointmentScheduleGrid({ providers, appointments, canBook, - canDelete = false, - onDeleteAppointment, onSlotClick, onAppointmentClick, }: AppointmentScheduleGridProps) { const gridHeight = HOURS.length * HOUR_PX; + const [overlapPopover, setOverlapPopover] = useState(null); + + const laneLayoutsByProvider = useMemo(() => { + const map = new Map>(); + for (const provider of providers) { + const providerApts = appointments.filter((a) => a.providerUserId === provider.userId); + map.set(provider.userId, computeAppointmentLaneLayouts(providerApts)); + } + return map; + }, [appointments, providers]); + + function handleAppointmentBannerClick( + apt: AppointmentRecord, + providerAppointments: AppointmentRecord[], + anchor: HTMLElement, + ) { + const cluster = findOverlapCluster(apt.id, providerAppointments); + if (cluster.length > 1) { + setOverlapPopover({ + appointments: cluster, + anchorRect: anchor.getBoundingClientRect(), + }); + return; + } + onAppointmentClick?.(apt); + } if (providers.length === 0) { return ( @@ -59,106 +110,145 @@ export function AppointmentScheduleGrid({ } return ( -
-
-
-
- {providers.map((p) => ( -
- {p.name} -
- ))} -
- -
-
- {HOURS.map((h) => ( -
- {formatHourLabel(h)} -
- ))} -
- -
+ <> +
+
+
+
{providers.map((p) => (
- {HOURS.map((h) => { - const slotDisabled = !canBook; - return ( - - )} - - ); - })} + {p.name}
))}
+ +
+
+ {HOURS.map((h) => ( +
+ {formatHourLabel(h)} +
+ ))} +
+ +
+ {providers.map((p) => { + const providerAppointments = appointments.filter( + (a) => a.providerUserId === p.userId, + ); + const laneLayouts = laneLayoutsByProvider.get(p.userId) ?? new Map(); + + return ( +
+ {HOURS.map((h) => { + const slotDisabled = !canBook; + return ( + + ); + })} +
+ ); + })} +
+
-
+ + {overlapPopover && ( + onAppointmentClick?.(apt)} + onClose={() => setOverlapPopover(null)} + /> + )} + ); } diff --git a/frontend/src/components/ui/appointments/appointmentPurposeStyles.ts b/frontend/src/components/ui/appointments/appointmentPurposeStyles.ts index 6230ad5..c0803ba 100644 --- a/frontend/src/components/ui/appointments/appointmentPurposeStyles.ts +++ b/frontend/src/components/ui/appointments/appointmentPurposeStyles.ts @@ -23,19 +23,6 @@ export function purposeStyle(purpose: string): string { return APPOINTMENT_PURPOSE_STYLES[p] ?? 'bg-surface-elevated border-border text-text-secondary'; } -/** Trash icon — legend hues; `!` overrides global `.lucide { color: var(--color-icon) }`. */ -export function purposeDeleteIconClass(purpose: string): string { - const p = purpose as AppointmentPurpose; - const map: Record = { - consultation: '!text-purpose-consultation-fg', - filling: '!text-purpose-filling-fg', - endo: '!text-purpose-endo-fg', - visit: '!text-purpose-visit-fg', - hygiene: '!text-purpose-hygiene-fg', - }; - return map[p] ?? '!text-text-muted'; -} - /** Small swatch for legend (background + border only). */ export const APPOINTMENT_PURPOSE_LEGEND_SWATCH: Record = { consultation: 'bg-violet-500/85 border-violet-400/75', diff --git a/frontend/src/components/ui/auth/OrganizationDetailsFields.tsx b/frontend/src/components/ui/auth/OrganizationDetailsFields.tsx new file mode 100644 index 0000000..957646a --- /dev/null +++ b/frontend/src/components/ui/auth/OrganizationDetailsFields.tsx @@ -0,0 +1,84 @@ +'use client'; + +import { Building2, Mail } from 'lucide-react'; +import type { FieldErrors, UseFormRegister, UseFormSetValue } from 'react-hook-form'; +import { Input } from '@/components/ui/common/Input'; + +export type OrganizationDetailsFormValues = { + organizationName: string; + organizationEmail: string; + organizationType: 'CLINIC' | 'LAB'; +}; + +type OrganizationDetailsFieldsProps = { + register: UseFormRegister; + errors: FieldErrors; + organizationType: 'CLINIC' | 'LAB' | undefined; + setValue: UseFormSetValue; +}; + +export function OrganizationDetailsFields({ + register, + errors, + organizationType, + setValue, +}: OrganizationDetailsFieldsProps) { + return ( + <> + } + /> + } + /> +
+ + +
+ + +
+ {errors.organizationType && ( +

{errors.organizationType.message}

+ )} +
+ + ); +} diff --git a/frontend/src/components/ui/auth/RegistrationProgressSteps.tsx b/frontend/src/components/ui/auth/RegistrationProgressSteps.tsx new file mode 100644 index 0000000..16d91de --- /dev/null +++ b/frontend/src/components/ui/auth/RegistrationProgressSteps.tsx @@ -0,0 +1,59 @@ +'use client'; + +import { ChevronRight } from 'lucide-react'; + +type RegistrationProgressStepsProps = { + step: number; + firstLabel?: string; + secondLabel?: string; +}; + +export function RegistrationProgressSteps({ + step, + firstLabel = 'Account', + secondLabel = 'Organization', +}: RegistrationProgressStepsProps) { + return ( +
+
+
+
= 1 + ? 'bg-primary text-primary-contrast' + : 'bg-background-secondary text-text-secondary border border-border' + }`} + > + 1 +
+
= 1 ? 'text-primary' : 'text-text-muted' + }`} + > + {firstLabel} +
+
+ +
+
= 2 + ? 'bg-primary text-primary-contrast' + : 'bg-background-secondary text-text-secondary border border-border' + }`} + > + 2 +
+
= 2 ? 'text-primary' : 'text-text-muted' + }`} + > + {secondLabel} +
+
+
+
+ ); +} diff --git a/frontend/src/components/ui/common/Badge.tsx b/frontend/src/components/ui/common/Badge.tsx index 823419d..642840d 100644 --- a/frontend/src/components/ui/common/Badge.tsx +++ b/frontend/src/components/ui/common/Badge.tsx @@ -43,8 +43,8 @@ export function Badge({ ); } -/** Map organization link / invitation row status to badge variant. */ -export function organizationLinkStatusVariant(status: string): BadgeVariant { +/** Map organization connection / invitation row status to badge variant. */ +export function organizationConnectionStatusVariant(status: string): BadgeVariant { switch (status) { case 'ACTIVE': return 'success'; diff --git a/frontend/src/components/ui/common/DialogCloseButton.tsx b/frontend/src/components/ui/common/DialogCloseButton.tsx new file mode 100644 index 0000000..07631d9 --- /dev/null +++ b/frontend/src/components/ui/common/DialogCloseButton.tsx @@ -0,0 +1,21 @@ +'use client'; + +import { X } from 'lucide-react'; + +type DialogCloseButtonProps = { + onClick: () => void; + className?: string; +}; + +export function DialogCloseButton({ onClick, className = '' }: DialogCloseButtonProps) { + return ( + + ); +} diff --git a/frontend/src/components/ui/common/ScheduleDayPicker.tsx b/frontend/src/components/ui/common/ScheduleDayPicker.tsx index e174d21..99518f5 100644 --- a/frontend/src/components/ui/common/ScheduleDayPicker.tsx +++ b/frontend/src/components/ui/common/ScheduleDayPicker.tsx @@ -1,48 +1,299 @@ 'use client'; -import { ChevronLeft, ChevronRight } from 'lucide-react'; -import { addCalendarDays } from '@/lib/appointmentTime'; +import { useEffect, useId, useRef, useState } from 'react'; +import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react'; +import { + addCalendarDays, + compareLocalDayStart, + startOfLocalDay, +} from '@/lib/appointmentTime'; interface ScheduleDayPickerProps { value: Date; onChange: (day: Date) => void; - /** Optional lower bound; picker navigation is unrestricted for history browsing. */ + /** Optional lower bound for day selection and previous-day navigation. */ minDate?: Date; label?: string; } -export function ScheduleDayPicker({ value, onChange, label = 'Schedule date' }: ScheduleDayPickerProps) { - const labelText = value.toLocaleDateString(undefined, { +const MONTH_LABELS = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', +] as const; + +function daysInMonth(year: number, month: number): number { + return new Date(year, month + 1, 0).getDate(); +} + +function buildLocalDay(year: number, month: number, day: number): Date { + return new Date(year, month, day, 0, 0, 0, 0); +} + +function clampToValidDay( + year: number, + month: number, + day: number, + min?: Date, +): Date { + const maxDay = daysInMonth(year, month); + let next = buildLocalDay(year, month, Math.min(Math.max(1, day), maxDay)); + if (min) { + const floor = startOfLocalDay(min); + if (compareLocalDayStart(next, floor) < 0) { + next = floor; + } + } + return next; +} + +function yearRange(min?: Date, anchor?: Date): number[] { + const now = new Date(); + const startYear = min ? min.getFullYear() : now.getFullYear() - 5; + const endYear = Math.max(now.getFullYear() + 2, anchor?.getFullYear() ?? now.getFullYear()); + const years: number[] = []; + for (let y = startYear; y <= endYear; y += 1) { + years.push(y); + } + return years; +} + +const selectClassName = ` + w-full appearance-none rounded-[var(--radius-sm)] border border-border + bg-background-card/90 text-text-primary text-sm + pl-2 pr-7 py-1.5 + focus:outline-none focus:ring-2 focus:ring-primary/35 focus:border-border-strong + disabled:opacity-50 disabled:cursor-not-allowed +`; + +export function ScheduleDayPicker({ + value, + onChange, + minDate, + label = 'Schedule date', +}: ScheduleDayPickerProps) { + const panelId = useId(); + const rootRef = useRef(null); + const [panelOpen, setPanelOpen] = useState(false); + + const normalizedValue = startOfLocalDay(value); + const normalizedMin = minDate ? startOfLocalDay(minDate) : undefined; + + const labelText = normalizedValue.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric', }); + const previousDay = addCalendarDays(normalizedValue, -1); + const canGoPrevious = + !normalizedMin || compareLocalDayStart(previousDay, normalizedMin) >= 0; + + const years = yearRange(normalizedMin, normalizedValue); + const selectedYear = normalizedValue.getFullYear(); + const selectedMonth = normalizedValue.getMonth(); + const selectedDay = normalizedValue.getDate(); + const dayCount = daysInMonth(selectedYear, selectedMonth); + + function applyParts(year: number, month: number, day: number, closePanel = false) { + onChange(clampToValidDay(year, month, day, normalizedMin)); + if (closePanel) { + setPanelOpen(false); + } + } + + function handlePreviousDay() { + if (!canGoPrevious) return; + onChange(previousDay); + } + + useEffect(() => { + if (!panelOpen) return; + + function onPointerDown(event: MouseEvent) { + if (!rootRef.current?.contains(event.target as Node)) { + setPanelOpen(false); + } + } + + function onKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') { + setPanelOpen(false); + } + } + + document.addEventListener('mousedown', onPointerDown); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('mousedown', onPointerDown); + document.removeEventListener('keydown', onKeyDown); + }; + }, [panelOpen]); + return ( -
+

{label}

-
- {labelText} -
+ + +
+ + {panelOpen && ( + + )}
); } diff --git a/frontend/src/components/ui/common/Toast.tsx b/frontend/src/components/ui/common/Toast.tsx index 094531e..f3073ca 100644 --- a/frontend/src/components/ui/common/Toast.tsx +++ b/frontend/src/components/ui/common/Toast.tsx @@ -24,3 +24,70 @@ export function Toast({ children, variant = 'default', className = '' }: ToastPr
); } + +export type ToastMessages = { + error?: string; + success?: string; + info?: string; + default?: string; +}; + +export type ToastStackProps = ToastMessages & { + className?: string; +}; + +function hasToastMessages(messages: ToastMessages): boolean { + return Boolean(messages.error || messages.success || messages.info || messages.default); +} + +/** Renders active toast messages with shared badge colors (success / warning / danger / default). */ +export function ToastStack({ error, success, info, default: defaultMessage, className = '' }: ToastStackProps) { + if (!hasToastMessages({ error, success, info, default: defaultMessage })) { + return null; + } + + return ( +
+ {error && {error}} + {info && {info}} + {success && {success}} + {defaultMessage && {defaultMessage}} +
+ ); +} + +export type ToastViewportPosition = 'inline' | 'top' | 'bottom'; + +export type ToastViewportProps = ToastStackProps & { + position?: ToastViewportPosition; +}; + +const viewportPositionClass: Record, string> = { + top: 'fixed top-4 left-0 right-0 z-[70] px-4 pointer-events-none', + bottom: 'fixed bottom-4 left-0 right-0 z-[70] px-4 pointer-events-none', +}; + +/** + * Positions a ToastStack on the page. Use `inline` below a heading; `bottom` / `top` for overlays. + */ +export function ToastViewport({ + position = 'inline', + className = '', + ...messages +}: ToastViewportProps) { + if (!hasToastMessages(messages)) { + return null; + } + + const stack = ; + + if (position === 'inline') { + return stack; + } + + return ( +
+
{stack}
+
+ ); +} diff --git a/frontend/src/components/ui/organizations/CopyInvitationLinkButton.tsx b/frontend/src/components/ui/organizations/CopyInvitationLinkButton.tsx new file mode 100644 index 0000000..a8df1c8 --- /dev/null +++ b/frontend/src/components/ui/organizations/CopyInvitationLinkButton.tsx @@ -0,0 +1,38 @@ +'use client'; + +import { Check, Copy } from 'lucide-react'; +import { + canShareOrganizationInviteLink, + type InvitationLinkTarget, +} from '@/components/invitations/organizationInviteLinks'; + +type CopyInvitationLinkButtonProps = { + invitation: InvitationLinkTarget; + copied: boolean; + copying: boolean; + onCopy: () => void; +}; + +export function CopyInvitationLinkButton({ + invitation, + copied, + copying, + onCopy, +}: CopyInvitationLinkButtonProps) { + if (!canShareOrganizationInviteLink(invitation)) { + return ; + } + + return ( + + ); +} diff --git a/frontend/src/components/ui/organizations/InvitationHistoryDialog.tsx b/frontend/src/components/ui/organizations/InvitationHistoryDialog.tsx new file mode 100644 index 0000000..1e04b42 --- /dev/null +++ b/frontend/src/components/ui/organizations/InvitationHistoryDialog.tsx @@ -0,0 +1,121 @@ +'use client'; + +import { DialogCloseButton } from '@/components/ui/common/DialogCloseButton'; +import { ToastStack, type ToastMessages } from '@/components/ui/common/Toast'; +import type { OrganizationInvitationHistoryItemDto } from '@/lib/api/organization'; +import { Badge, organizationConnectionStatusVariant } from '@/components/ui/common/Badge'; +import { Table } from '@/components/ui/common/Table'; +import { CopyInvitationLinkButton } from '@/components/ui/organizations/CopyInvitationLinkButton'; + +function formatInvitationStatusLabel(status: OrganizationInvitationHistoryItemDto['status']): string { + if (status === 'PENDING') return 'Invitation pending'; + if (status === 'ACTIVE') return 'Invitation accepted'; + if (status === 'REJECTED') return 'Invitation rejected'; + if (status === 'EXPIRED') return 'Invitation expired'; + return status; +} + +function formatTableDate(value: string): string { + const d = new Date(value); + if (Number.isNaN(d.getTime())) return '—'; + return d.toLocaleDateString(); +} + +type InvitationHistoryDialogProps = { + open: boolean; + onClose: () => void; + loading: boolean; + items: OrganizationInvitationHistoryItemDto[]; + copiedId: string | null; + copyingInvitationId: string | null; + onCopy: (invitation: OrganizationInvitationHistoryItemDto) => void; + /** Same page-level toasts, rendered at top of dialog while it is open. */ + toastMessages?: ToastMessages; +}; + +export function InvitationHistoryDialog({ + open, + onClose, + loading, + items, + copiedId, + copyingInvitationId, + onCopy, + toastMessages, +}: InvitationHistoryDialogProps) { + if (!open) return null; + + return ( +
+
+
+

+ Invitation History +

+ +
+ + {toastMessages && } + + {loading ? ( +

Loading invitation history...

+ ) : items.length === 0 ? ( +

No invitations yet.

+ ) : ( + + + + + + + + } + body={ + <> + {items.map((inv) => ( + + + + + + + + ))} + + } + /> + )} + + + ); +} diff --git a/frontend/src/components/ui/organization/OrganizationSelectorContent.tsx b/frontend/src/components/ui/organizations/OrganizationSelectorContent.tsx similarity index 100% rename from frontend/src/components/ui/organization/OrganizationSelectorContent.tsx rename to frontend/src/components/ui/organizations/OrganizationSelectorContent.tsx diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index 47319d7..409d9b8 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -15,6 +15,17 @@ export const apiClient = axios.create({ timeout: 10000, }); +/** Invitation preview/accept must work with no cookies (public API, no JWT). */ +function isPublicInvitationRequest(url: string | undefined): boolean { + if (!url) return false; + return ( + url.includes('/staff/invitations/preview') || + url.includes('/staff/invitations/accept') || + url.includes('/organizations/invitations/preview') || + url.includes('/organizations/invitations/accept') + ); +} + // ❌ REMOVE request interceptor completely (no Authorization header) // ✅ Response interceptor @@ -23,7 +34,11 @@ apiClient.interceptors.response.use( async (error: AxiosError) => { const originalRequest = error.config as CustomAxiosRequestConfig; - if (error.response?.status === 401 && !originalRequest._retry) { + if ( + error.response?.status === 401 && + !originalRequest._retry && + !isPublicInvitationRequest(originalRequest.url) + ) { originalRequest._retry = true; try { diff --git a/frontend/src/lib/api/organization.ts b/frontend/src/lib/api/organization.ts index bd65b4b..7c940ce 100644 --- a/frontend/src/lib/api/organization.ts +++ b/frontend/src/lib/api/organization.ts @@ -18,6 +18,12 @@ export interface CounterpartItemDto { status: 'PENDING' | 'ACTIVE' | 'REJECTED' | 'EXPIRED'; createdAt: string; acceptedAt: string | null; + /** + * Set by GET /organizations/connections when this PENDING row came from inviteOrganization() + * (joined server-side). Lets the main table show copy-invite without opening history. + */ + pendingInvitationId?: string | null; + invitationStatus?: OrganizationInvitationHistoryItemDto['status'] | null; } export interface OrganizationInvitationHistoryItemDto { @@ -36,7 +42,7 @@ export const organizationApi = { }, list: async (): Promise<{ success: boolean; data: { items: CounterpartItemDto[] } }> => { - const response = await apiClient.get('/organizations/links'); + const response = await apiClient.get('/organizations/connections'); return response.data; }, @@ -48,23 +54,27 @@ export const organizationApi = { return response.data; }, - createLink: async ( + createConnectionRequest: async ( targetOrganizationId: string, ): Promise<{ success: boolean; data: { id: string; status: 'PENDING' | 'ACTIVE' | 'REJECTED' } }> => { - const response = await apiClient.post('/organizations/links', { targetOrganizationId }); + const response = await apiClient.post('/organizations/connections', { targetOrganizationId }); return response.data; }, - respondLink: async ( - linkId: string, + respondToConnectionRequest: async ( + connectionId: string, action: 'ACCEPT' | 'REJECT', ): Promise<{ success: boolean; data: { id: string; status: 'PENDING' | 'ACTIVE' | 'REJECTED' } }> => { - const response = await apiClient.patch(`/organizations/links/${linkId}/respond`, { action }); + const response = await apiClient.patch(`/organizations/connections/${connectionId}/respond`, { + action, + }); return response.data; }, - deleteLink: async (linkId: string): Promise<{ success: boolean; data: { id: string }; message: string }> => { - const response = await apiClient.delete(`/organizations/links/${linkId}`); + deleteConnection: async ( + connectionId: string, + ): Promise<{ success: boolean; data: { id: string }; message: string }> => { + const response = await apiClient.delete(`/organizations/connections/${connectionId}`); return response.data; }, @@ -92,6 +102,7 @@ export const organizationApi = { ownerEmail: string; organizationName: string; organizationType: 'CLINIC' | 'LAB'; + organizationEmail?: string; inviterOrganizationName: string; expiresAt: string; status: 'PENDING' | 'ACCEPTED'; @@ -106,6 +117,8 @@ export const organizationApi = { acceptInvite: async (body: { token: string; organizationName: string; + organizationEmail: string; + organizationType: 'CLINIC' | 'LAB'; ownerName: string; password: string; }): Promise<{ success: boolean; message: string; data: { organizationId: string } }> => { diff --git a/frontend/src/lib/api/staff.ts b/frontend/src/lib/api/staff.ts index 25bfee6..1852b65 100644 --- a/frontend/src/lib/api/staff.ts +++ b/frontend/src/lib/api/staff.ts @@ -63,6 +63,21 @@ export const staffApi = { return response.data; }, + getInvitationLink: async ( + membershipId: string, + ): Promise<{ + success: boolean; + data: { + membershipId: string; + invitationId: string; + email: string; + invitationUrl: string; + }; + }> => { + const response = await apiClient.post(`/staff/members/${membershipId}/invitation-link`); + return response.data; + }, + previewInvite: async (token: string): Promise => { const response = await apiClient.get(`/staff/invitations/preview?token=${encodeURIComponent(token)}`); return response.data; diff --git a/frontend/src/lib/appointmentOverlapLayout.ts b/frontend/src/lib/appointmentOverlapLayout.ts new file mode 100644 index 0000000..ae1efa0 --- /dev/null +++ b/frontend/src/lib/appointmentOverlapLayout.ts @@ -0,0 +1,173 @@ +import type { AppointmentRecord } from '@/types/appointment'; + +export type AppointmentTimedInterval = { + id: string; + start: number; + end: number; +}; + +export type AppointmentLaneLayout = { + lane: number; + /** Max concurrent overlaps in this appointment's cluster (column count). */ + laneCount: number; +}; + +function intervalsOverlap(a: AppointmentTimedInterval, b: AppointmentTimedInterval): boolean { + return a.start < b.end && b.start < a.end; +} + +export function toTimedInterval(apt: AppointmentRecord): AppointmentTimedInterval { + return { + id: apt.id, + start: new Date(apt.startAt).getTime(), + end: new Date(apt.endAt).getTime(), + }; +} + +/** Connected overlap component containing `appointmentId`. */ +export function findOverlapCluster( + appointmentId: string, + appointments: AppointmentRecord[], +): AppointmentRecord[] { + const byId = new Map(appointments.map((a) => [a.id, a])); + if (!byId.has(appointmentId)) { + return []; + } + + const timed = appointments.map(toTimedInterval); + const clusterIds = new Set([appointmentId]); + let changed = true; + + while (changed) { + changed = false; + for (const interval of timed) { + if (clusterIds.has(interval.id)) { + continue; + } + for (const memberId of clusterIds) { + const member = timed.find((t) => t.id === memberId); + if (member && intervalsOverlap(interval, member)) { + clusterIds.add(interval.id); + changed = true; + break; + } + } + } + } + + return appointments.filter((a) => clusterIds.has(a.id)); +} + +function maxConcurrentCount(intervals: AppointmentTimedInterval[]): number { + if (intervals.length === 0) { + return 0; + } + + type Point = { time: number; delta: number }; + const points: Point[] = []; + for (const interval of intervals) { + points.push({ time: interval.start, delta: 1 }); + points.push({ time: interval.end, delta: -1 }); + } + points.sort((a, b) => a.time - b.time || a.delta - b.delta); + + let current = 0; + let max = 0; + for (const point of points) { + current += point.delta; + max = Math.max(max, current); + } + return max; +} + +function assignGreedyLanes(intervals: AppointmentTimedInterval[]): Map { + const sorted = [...intervals].sort((a, b) => a.start - b.start || a.end - b.end); + const laneEndTimes: number[] = []; + const laneById = new Map(); + + for (const interval of sorted) { + let lane = laneEndTimes.findIndex((end) => end <= interval.start); + if (lane === -1) { + lane = laneEndTimes.length; + laneEndTimes.push(interval.end); + } else { + laneEndTimes[lane] = interval.end; + } + laneById.set(interval.id, lane); + } + + return laneById; +} + +function buildClusters(intervals: AppointmentTimedInterval[]): AppointmentTimedInterval[][] { + const visited = new Set(); + const clusters: AppointmentTimedInterval[][] = []; + + for (const seed of intervals) { + if (visited.has(seed.id)) { + continue; + } + const cluster: AppointmentTimedInterval[] = []; + const queue = [seed]; + visited.add(seed.id); + while (queue.length > 0) { + const current = queue.pop()!; + cluster.push(current); + for (const other of intervals) { + if (!visited.has(other.id) && intervalsOverlap(current, other)) { + visited.add(other.id); + queue.push(other); + } + } + } + clusters.push(cluster); + } + + return clusters; +} + +/** + * Assigns side-by-side lanes per provider column (Google Calendar style). + */ +export function computeAppointmentLaneLayouts( + appointments: AppointmentRecord[], +): Map { + const timed = appointments.map(toTimedInterval); + if (timed.length === 0) { + return new Map(); + } + + const layouts = new Map(); + const clusters = buildClusters(timed); + + for (const cluster of clusters) { + const laneCount = Math.max(1, maxConcurrentCount(cluster)); + const greedyLanes = assignGreedyLanes(cluster); + const usedLaneIndices = [...new Set(cluster.map((c) => greedyLanes.get(c.id) ?? 0))].sort( + (a, b) => a - b, + ); + const remap = new Map(usedLaneIndices.map((lane, index) => [lane, index])); + + for (const interval of cluster) { + const rawLane = greedyLanes.get(interval.id) ?? 0; + layouts.set(interval.id, { + lane: remap.get(rawLane) ?? 0, + laneCount, + }); + } + } + + return layouts; +} + +export function lanePositionStyles(lane: number, laneCount: number): { + left: string; + width: string; +} { + const gapPct = 1; + const widthPct = (100 - gapPct * (laneCount + 1)) / laneCount; + return { + left: `calc(${gapPct}% + ${lane} * (${widthPct}% + ${gapPct}%))`, + width: `${widthPct}%`, + }; +} diff --git a/frontend/src/lib/hooks/useOrganizationInviteLinkCopy.ts b/frontend/src/lib/hooks/useOrganizationInviteLinkCopy.ts new file mode 100644 index 0000000..2bed789 --- /dev/null +++ b/frontend/src/lib/hooks/useOrganizationInviteLinkCopy.ts @@ -0,0 +1,107 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { + organizationApi, + type OrganizationInvitationHistoryItemDto, +} from '@/lib/api/organization'; +import { + canShareOrganizationInviteLink, + readOrganizationInviteLinks, + type StoredOrganizationInviteLink, + writeOrganizationInviteLinks, +} from '@/components/invitations/organizationInviteLinks'; + +export function useOrganizationInviteLinkCopy(organizationId: string | undefined) { + const [pendingInviteLinks, setPendingInviteLinks] = useState< + Record + >({}); + const [copiedId, setCopiedId] = useState(null); + const [copyingInvitationId, setCopyingInvitationId] = useState(null); + + useEffect(() => { + if (!organizationId) return; + setPendingInviteLinks(readOrganizationInviteLinks(organizationId)); + }, [organizationId]); + + const storeInviteLink = useCallback( + (invitationId: string, ownerEmail: string, invitationUrl: string) => { + if (!organizationId) return; + setPendingInviteLinks((prev) => { + const next = { + ...prev, + [invitationId]: { + invitationId, + ownerEmail: ownerEmail.trim().toLowerCase(), + invitationUrl, + }, + }; + writeOrganizationInviteLinks(organizationId, next); + return next; + }); + }, + [organizationId], + ); + + const pruneAcceptedLinks = useCallback( + (items: OrganizationInvitationHistoryItemDto[]) => { + if (!organizationId) return; + const acceptedIds = new Set( + items.filter((item) => item.acceptedAt || item.status === 'ACTIVE').map((item) => item.id), + ); + + setPendingInviteLinks((prev) => { + let changed = false; + const next = { ...prev }; + for (const id of Object.keys(next)) { + if (acceptedIds.has(id)) { + delete next[id]; + changed = true; + } + } + if (changed) { + writeOrganizationInviteLinks(organizationId, next); + } + return changed ? next : prev; + }); + }, + [organizationId], + ); + + const copyInvitationLink = useCallback( + async ( + invitation: OrganizationInvitationHistoryItemDto, + options?: { onRegenerated?: () => void | Promise }, + ): Promise => { + if (!canShareOrganizationInviteLink(invitation)) return null; + + setCopyingInvitationId(invitation.id); + try { + let invitationUrl = pendingInviteLinks[invitation.id]?.invitationUrl; + if (!invitationUrl || invitation.status === 'EXPIRED') { + const res = await organizationApi.getInvitationLink(invitation.id); + invitationUrl = res.data.invitationUrl; + storeInviteLink(invitation.id, invitation.ownerEmail, invitationUrl); + await options?.onRegenerated?.(); + } + + await navigator.clipboard.writeText(invitationUrl); + setCopiedId(invitation.id); + setTimeout(() => setCopiedId(null), 1500); + return invitationUrl; + } finally { + setCopyingInvitationId(null); + } + }, + [pendingInviteLinks, storeInviteLink], + ); + + return { + pendingInviteLinks, + copiedId, + copyingInvitationId, + storeInviteLink, + copyInvitationLink, + pruneAcceptedLinks, + }; +} diff --git a/frontend/src/lib/hooks/useToast.ts b/frontend/src/lib/hooks/useToast.ts new file mode 100644 index 0000000..4d36e78 --- /dev/null +++ b/frontend/src/lib/hooks/useToast.ts @@ -0,0 +1,103 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import type { ToastMessages } from '@/components/ui/common/Toast'; + +const DEFAULT_DURATION_MS = 4000; + +export type UseToastOptions = { + successMs?: number; + errorMs?: number; + infoMs?: number; + defaultMs?: number; +}; + +export function useToast(options: UseToastOptions = {}) { + const successMs = options.successMs ?? DEFAULT_DURATION_MS; + const errorMs = options.errorMs ?? DEFAULT_DURATION_MS; + const infoMs = options.infoMs ?? DEFAULT_DURATION_MS; + const defaultMs = options.defaultMs ?? DEFAULT_DURATION_MS; + + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + const [info, setInfo] = useState(''); + const [defaultMessage, setDefaultMessage] = useState(''); + + useEffect(() => { + if (!success) return; + const id = setTimeout(() => setSuccess(''), successMs); + return () => clearTimeout(id); + }, [success, successMs]); + + useEffect(() => { + if (!error) return; + const id = setTimeout(() => setError(''), errorMs); + return () => clearTimeout(id); + }, [error, errorMs]); + + useEffect(() => { + if (!info) return; + const id = setTimeout(() => setInfo(''), infoMs); + return () => clearTimeout(id); + }, [info, infoMs]); + + useEffect(() => { + if (!defaultMessage) return; + const id = setTimeout(() => setDefaultMessage(''), defaultMs); + return () => clearTimeout(id); + }, [defaultMessage, defaultMs]); + + const clear = useCallback(() => { + setError(''); + setSuccess(''); + setInfo(''); + setDefaultMessage(''); + }, []); + + const showError = useCallback((message: string) => { + setSuccess(''); + setInfo(''); + setDefaultMessage(''); + setError(message); + }, []); + + const showSuccess = useCallback((message: string) => { + setError(''); + setInfo(''); + setDefaultMessage(''); + setSuccess(message); + }, []); + + const showInfo = useCallback((message: string) => { + setError(''); + setSuccess(''); + setDefaultMessage(''); + setInfo(message); + }, []); + + const showDefault = useCallback((message: string) => { + setError(''); + setSuccess(''); + setInfo(''); + setDefaultMessage(message); + }, []); + + const messages: ToastMessages = { error, success, info, default: defaultMessage }; + + return { + error, + success, + info, + defaultMessage, + setError, + setSuccess, + setInfo, + setDefaultMessage, + showError, + showSuccess, + showInfo, + showDefault, + clear, + messages, + }; +} diff --git a/frontend/src/proxy.ts b/frontend/src/proxy.ts index 4186564..e70bcd9 100644 --- a/frontend/src/proxy.ts +++ b/frontend/src/proxy.ts @@ -1,7 +1,17 @@ import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; -const publicRoutes = ['/', '/login', '/register', '/terms', '/privacy', '/forgot-password']; +/** Routes that must work without an existing session (first-time invitees). */ +const publicRoutes = [ + '/', + '/login', + '/register', + '/terms', + '/privacy', + '/forgot-password', + '/accept-invite', + '/accept-organization-invite', +]; export function proxy(request: NextRequest) { const { pathname } = request.nextUrl; diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css index 6205780..ccb02d4 100644 --- a/frontend/src/styles/globals.css +++ b/frontend/src/styles/globals.css @@ -81,18 +81,18 @@ --color-card-muted: #b6c6dd; --color-card-border: #29456a; - --color-badge-default-bg: #1b2f4e; - --color-badge-default-fg: #b6c6dd; - --color-badge-default-border: #3b5f8f; - --color-badge-success-bg: rgba(6, 78, 59, 0.45); - --color-badge-success-fg: #86efac; - --color-badge-success-border: rgba(21, 128, 61, 0.5); - --color-badge-warning-bg: rgba(120, 53, 15, 0.48); - --color-badge-warning-fg: #fcd34d; - --color-badge-warning-border: rgba(180, 83, 9, 0.55); - --color-badge-danger-bg: rgba(127, 29, 29, 0.42); - --color-badge-danger-fg: #fca5a5; - --color-badge-danger-border: rgba(185, 28, 28, 0.52); + --color-badge-default-bg: rgba(27, 47, 78, 0.32); + --color-badge-default-fg: #9eb1cb; + --color-badge-default-border: rgba(59, 95, 143, 0.32); + --color-badge-success-bg: rgba(6, 78, 59, 0.22); + --color-badge-success-fg: #7dd3a8; + --color-badge-success-border: rgba(34, 100, 68, 0.28); + --color-badge-warning-bg: rgba(120, 53, 15, 0.22); + --color-badge-warning-fg: #dfc06a; + --color-badge-warning-border: rgba(146, 88, 20, 0.28); + --color-badge-danger-bg: rgba(127, 29, 29, 0.22); + --color-badge-danger-fg: #e4a6a6; + --color-badge-danger-border: rgba(153, 50, 50, 0.28); --color-purpose-consultation-bg: rgba(139, 92, 246, 0.25); --color-purpose-consultation-fg: #ddd6fe; @@ -139,18 +139,18 @@ --color-card-muted: #b6c6dd; --color-card-border: #29456a; - --color-badge-default-bg: #1b2f4e; - --color-badge-default-fg: #b6c6dd; - --color-badge-default-border: #3b5f8f; - --color-badge-success-bg: rgba(6, 78, 59, 0.45); - --color-badge-success-fg: #86efac; - --color-badge-success-border: rgba(21, 128, 61, 0.5); - --color-badge-warning-bg: rgba(120, 53, 15, 0.48); - --color-badge-warning-fg: #fcd34d; - --color-badge-warning-border: rgba(180, 83, 9, 0.55); - --color-badge-danger-bg: rgba(127, 29, 29, 0.42); - --color-badge-danger-fg: #fca5a5; - --color-badge-danger-border: rgba(185, 28, 28, 0.52); + --color-badge-default-bg: rgba(27, 47, 78, 0.32); + --color-badge-default-fg: #9eb1cb; + --color-badge-default-border: rgba(59, 95, 143, 0.32); + --color-badge-success-bg: rgba(6, 78, 59, 0.22); + --color-badge-success-fg: #7dd3a8; + --color-badge-success-border: rgba(34, 100, 68, 0.28); + --color-badge-warning-bg: rgba(120, 53, 15, 0.22); + --color-badge-warning-fg: #dfc06a; + --color-badge-warning-border: rgba(146, 88, 20, 0.28); + --color-badge-danger-bg: rgba(127, 29, 29, 0.22); + --color-badge-danger-fg: #e4a6a6; + --color-badge-danger-border: rgba(153, 50, 50, 0.28); --color-purpose-consultation-bg: rgba(139, 92, 246, 0.25); --color-purpose-consultation-fg: #ddd6fe; @@ -254,6 +254,11 @@ body { border-radius: var(--radius-lg); } +:root[data-theme='dark'] .surface-card, +:root:not([data-theme='light']) .surface-card { + background: color-mix(in srgb, var(--color-card-background) 82%, var(--color-background-primary)); +} + .surface-panel { background: color-mix(in srgb, var(--color-background-secondary) 96%, transparent); border: 1px solid var(--color-border); diff --git a/infrastructure/.env.example b/infrastructure/.env.example index d96c8b7..b3c2417 100644 --- a/infrastructure/.env.example +++ b/infrastructure/.env.example @@ -12,9 +12,13 @@ DOMAIN=dyolink.com # Backend Environment (create backend.env from this) # NODE_ENV=production -# JWT_SECRET=CHANGE_THIS_TO_STRONG_SECRET_32_CHARS +# PORT=3000 # DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} -# CORS_ORIGIN=https://dyolink.com +# JWT_SECRET=CHANGE_THIS_TO_STRONG_SECRET_32_CHARS +# JWT_EXPIRES_IN=15m +# JWT_REFRESH_SECRET=another_long_random_secret_different_from_JWT_SECRET +# JWT_REFRESH_EXPIRES_IN=30d +# FRONTEND_URL=https://dyolink.com # Frontend Environment (create frontend.env from this) # NEXT_PUBLIC_API_URL=/api diff --git a/infrastructure/backend.staging.env.example b/infrastructure/backend.staging.env.example index 19f6ab8..9c62d4a 100644 --- a/infrastructure/backend.staging.env.example +++ b/infrastructure/backend.staging.env.example @@ -9,4 +9,5 @@ JWT_EXPIRES_IN=15m JWT_REFRESH_SECRET=another_long_random_secret_different_from_JWT_SECRET JWT_REFRESH_EXPIRES_IN=30d +# CORS, cookies, and invite links — must match how users open the app (nginx host port) FRONTEND_URL=http://178.131.50.201:8088
+ Organization + + Owner email + + Date + + Status + + Invitation link +
{inv.organizationName}{inv.ownerEmail} + {formatTableDate(inv.createdAt)} + + + {formatInvitationStatusLabel(inv.status)} + + + onCopy(inv)} + /> +