Skip to content

Projects

Lucas Augusto Muniz Menezes

iPsiq

Co-founder and full stack engineer – present

Overview

Platform in development for scheduling and paying for mental health consultations, with a dedicated page for each doctor and a back-office for the team.

  • Designed and built the back-end (NestJS, TypeORM, PostgreSQL, Zod) and front-end (React, Vite, TanStack Query, Tailwind).
  • Booking with temporary holds and double-booking protection, cascading prices by specialty and doctor, and audit logs written in the same transaction.
  • Payment split at booking time via Asaas (sub-accounts, invoices and subscriptions), in progress.
  • Full local environment in Docker with LocalStack (S3, SES), automatic AWS deploy on every push, and an LGPD data-anonymization flow.

Quality and delivery

  • Over 500 automated tests, continuous integration on every push and automatic AWS deploys.
  • About 48 thousand lines of TypeScript, 36 database migrations and 240 commits in three weeks.
  • Isolation between professionals (none can reach another's data, not even through the API) is proven by integration tests in CI.

Security and access control

  • Closed by default: every route declares who may call it, and roles are sets of permissions, with per-person extra permissions, always with a reason and history.
  • A read-only profile that shows the patient only by code and hides name, e-mail, phone and ID number, with a test that walks every route.
  • Sessions with a refresh token in an HttpOnly cookie, login-attempt lockout, booking limits per IP and per patient, and third-party keys encrypted.
  • Audit of everything that changes in the system, written in the same transaction, filterable by period, person, doctor and patient code.

Privacy and LGPD

  • Data minimization: no patient data in logs, URLs or ad events, and the full ID number is not stored, only masked and as a keyed hash for lookups.
  • Prescription data is collected only after payment, encrypted, with each doctor access audited; the platform does not issue prescriptions.
  • Data subject rights: on-demand patient anonymization, ad source erased after 180 days, and an encrypted database backup with limited retention.
  • Cookies: no pixel before consent, choice by category and recorded consent; hashed e-mail and phone are not sent to ad platforms.

CFM and professional-council rules (CRM)

  • Each professional is shown with their council registration (CRM, CRP or CRN); psychologists and nutritionists are not shown as doctors or with a medical specialty.
  • A medical specialty is only displayed with an RQE, following CFM rules; without an RQE the professional appears simply as "Doctor".
  • Each public profile's label follows CFM Resolution 2,336/2023, on medical advertising.
  • A professional only goes live on the site after completing the activation checklist and being activated by the team.

AWS infrastructure

  • Secrets in SSM Parameter Store, transactional e-mails in SES and photos in S3 with signed URLs.
  • Daily database backup to S3, encrypted with 30-day retention, and automatic deploys on every push to the main branch.

Public site

What the patient sees: the home page and each professional's page, with booking in a single flow.

  • The chosen slot is held for a while, with protection against double booking.
  • Payment is made via Pix at booking time, and the appointment is confirmed right away.
  • Rescheduling is free, with 24 hours' notice.

Screenshots from a development environment, with fictitious data.

iPsiq public home page with the value proposition and the next available slot
Public home page: value proposition and next available slot.
Day and time picker on a doctor's page, with the booking button
Booking: pick the day and time, and confirm in the same flow.

Doctor area

Each professional signs in with their own login and sees only their own data: agenda, appointments, invoices and schedule.

  • No professional can reach another's data, not even through the API: this is proven by integration tests in CI.
  • The appointment is started with the Start button, enabled 10 minutes before; appointments not started within 15 minutes after the scheduled time are refunded automatically.
  • With Google Calendar connected, each appointment gets its own Meet room, created in the professional's own calendar; the event carries only "iPsiq appointment" and the patient's first name.
  • Weekly availability and time off; changes do not affect appointments already booked.

Screenshots from a development environment, with fictitious data.

Day agenda in the doctor area, with the Google Calendar connection and the day's appointments
Doctor area: day agenda, with the Google Calendar connection and each appointment's actions (start, no-show, reschedule, cancel).
Professional's appointment list filtered by status
Doctor area: the professional's own appointments, with status and prescription-data state.
Weekly availability and blocked time in the doctor area
Doctor area: availability by day and time off, which the patient sees when booking.

Back-office

The team follows the agenda, appointments, doctors and schedules in one place.

  • Appointment search by name, e-mail, phone, ID number or patient code, with status and doctor filters.
  • Quick agenda actions: complete, mark no-show, reschedule and cancel; rescheduling requires a reason, which goes to the audit trail.
  • Command palette (Ctrl K) to jump straight to any screen or setting.

Screenshots from a development environment, with fictitious data.

Back-office day agenda, showing each appointment's status and quick actions
Back-office: day agenda with each appointment's status and quick actions (complete, no-show, reschedule, cancel).
Back-office appointment list filtered by status
Back-office: appointments with search, status and doctor filters, and pagination.
Weekly availability by weekday and a blocked-time form
Weekly availability, with copy between days, and blocks for vacations and appointments.
Command palette open over the roles list
Command palette (Ctrl K) to jump straight to any screen or setting.

Access and permissions

Permission-based RBAC: roles are sets of resource-and-action permissions, access is closed by default and every route declares who may call it.

  • A permission is a resource:action pair, a role is a set of permissions and each person has one role; on every request the guard combines the role with the person's extra permissions that have not been revoked.
  • The catalog lives in a typed file: the decorator only accepts permissions that exist. On startup, the app syncs the catalog with the database (creates and removes permissions), with auditing.
  • Audience: every role and permission belongs to staff or to doctors, and doctor permissions (own-*) can only join doctor roles, which the database also enforces. Per-doctor isolation lives in the route: the permission says what a person may do, not on whose data.
  • System roles: the Administrator is fixed (always every permission, not editable, so nobody locks themselves out); Doctor and Product Manager can be adjusted on screen, and the file is only a starting point: the sync never undoes what was changed there. None can be deleted or renamed.
  • Custom roles are built in a resource-by-action matrix, and each role has its history (who changed it, when, what came in and what went out); a role that has users cannot be deleted.
  • Grant rules: nobody grants a permission they do not have, nobody grants themselves a permission, and a role change is never one's own, never to a different audience and never of the last administrator.
  • Per-person extra permissions require a reason when granted and when revoked, record who granted them and when, show up in the same timeline as the audit, and take effect on the next action, with no new login.
  • Product Manager, read-only: view permissions only, with the exact list of routes locked in a test (a new route stays closed to this role until someone adds the permission on purpose) and patients only by code; a test walks all of its routes with real data and fails if any personal data shows up.
  • The front end decides areas and buttons from the permissions (audience and permissions come from the session endpoint), with guards by audience and by permission, and the server remains the one that decides.
// permissions.config.ts
export const PERMISSIONS = {
  'recurso:ver': { audience: 'staff' },
  'recurso:editar': { audience: 'staff' },
  'own-agenda:view': { audience: 'doctor' },
} as const;

export type Permission = keyof typeof PERMISSIONS;

// @Permissions('recurso:ver')    -> compila
// @Permissions('recurso:vre')    -> erro de compilação
Illustrative example (generic names) of the typed catalog: the decorator only accepts the keys that exist here.

Screenshots from a development environment, with fictitious data.

List of system roles with type, audience, permissions and users
Access: system roles (administrator, doctor and product manager) as sets of permissions.
Permission matrix by resource and action for a role
Permission matrix by resource and action; custom roles are built here.

Code and decorators

The back-end is NestJS and TypeScript, with security and validation declared on the route itself, through decorators, pipes and global filters, and checked by tests.

  • Four decorators cover every route's access: @Public() (open), @Authenticated() (any logged-in person) and @Permissions(...) (the role must grant all of the permissions), plus @CurrentUser() and @CurrentDoctorId() to read the person from the session.
  • The global guard fails closed: a route that declares no access is refused, and a test (access-control.spec) walks every route to guarantee it.
  • Permission names in @Permissions are checked at compile time against a typed catalog, so a typo does not compile; the catalog is synced with the database on startup, with auditing.
  • The token is just the key: role, permissions and doctor come from the live session on every request, so logout, deactivation, password changes and role changes take effect on the next call.
  • @CurrentDoctorId() hands over the session's doctor: doctor routes never receive the id from the URL or the body, which closes the door to reading another doctor's data (IDOR).
  • Validation at the edge: the ZodValidationPipe validates and transforms the body with a Zod schema and returns one message per invalid field, in the path: message format.
  • A global exception filter standardizes the API error, maps Postgres integrity errors to 409 or 400 instead of 500, hides the real message of unexpected errors in production and logs every 5xx, without letting link tokens reach the logs.
  • Personal data: field encryption with AES-256-GCM, with the key outside the database, and a keyed hash (HMAC-SHA256) to look a value up without storing it, such as the ID number.
  • Every repository is tenant-scoped: the tenantId is part of every query and every update, which prevents access across contexts.
  • Passwords with Argon2 and a password policy, login-attempt lockout per account and a rate limiter on the login routes.
  • Standards the team follows and tests enforce: writes only through repositories (a test rejects raw SQL), migrations with a working rollback, typed enums, generic components on the front end, and code comments in English with on-screen text in Portuguese.
  • make check runs types, lint and tests inside Docker, and CI repeats it on every push.
@Controller('exemplo')
export class ExemploController {
  // Qualquer pessoa logada, seja qual for a role.
  @Get('eu')
  @Authenticated()
  quemSouEu(@CurrentUser() user: AuthenticatedUser) { /* ... */ }

  // A role precisa conceder TODAS as permissões citadas;
  // o nome é checado em tempo de compilação contra o catálogo tipado.
  @Post()
  @Permissions('recurso:acao')
  criar(@Body(new ZodValidationPipe(CriarSchema)) dto: CriarDto) { /* ... */ }

  // O médico vem da sessão, nunca da URL nem do corpo.
  @Get('minha-agenda')
  @Permissions('own-agenda:view')
  agenda(@CurrentDoctorId() doctorId: string) { /* ... */ }

  // Rota aberta (ex.: página pública), declarada de propósito.
  @Public()
  @Get('publico')
  publico() { /* ... */ }
}
Illustrative example (generic names) of how access and validation are declared on the route.

Audit and history

Every data change leaves a record of who did it, what, when and what changed, and the history cannot be edited or deleted.

  • Every repository extends a BaseRepository: every write (create, update, delete and restore) records its audit event in the same transaction as the business write, so either both are saved or neither is.
  • Failed writes are also recorded, with a failure result, outside the failed transaction.
  • The caller states exactly which fields changed, so what is audited is what changed; a deletion keeps the deleted record so the history says what it was.
  • Each record has a version (1, 2, 3…) per entity, with "see this record's history" and "see everything from this person".
  • The audit table is append-only: the database itself refuses UPDATE, DELETE and TRUNCATE (LGPD art. 37: the record of operations must be trustworthy).
  • Passwords, tokens, ID numbers, e-mails, IPs and patient data are masked in the diff, because the trail cannot be erased; patients appear only by code.
  • All business tables have history: a test fails if a raw INSERT, UPDATE or DELETE shows up outside the exceptions. Only technical records (login counters, e-mail and webhook sent marks, OAuth codes and session cleanup) are left out, with an explicit, justified audit: false.
  • Events become sentences in the team's own words (for example, "Appointment booked by the patient on the site" or "Login (staff)"), each with a title, a detail and a tone (access, problem…); the list shows only the names of the fields that changed, and the full diff is one click away.
  • The trail brings different sources into a single timeline: data changes, e-mails sent, payment provider notices (webhooks) and ad conversions, with login failures and session-refresh rejections also recorded as events.
  • Each event is linked to what it touches (doctor, appointment and patient, by code), which allows filtering by period, source, entity, operation, result, doctor, patient code and who did it (staff, doctor, patient or system), and grouping the history by patient.
  • A "Hide" filter hides what is very common (logins, login failures, e-mails, webhooks and conversions) so the rest stands out, and new entities show up in the audit on their own, by technical name, even before they get a label in the catalog.
  • Future: every write goes through a single point (writeAuditEvent), synchronous and in the same transaction today because iPsiq has no queue. Since the write is idempotent by id, that point can be swapped for an event emission (outbox pattern) to move auditing to a microservice, without touching the repositories' code.

Screenshots from a development environment, with fictitious data.

Audit screen with filters by period, source, entity and operation
Audit: everything that happens in the system, with when, what and who filters; patients only by code.
Doctors' audit, with history grouped by patient code
Doctors' audit: history grouped by patient, identified only by code.
Timeline of a patient's events in the audit
A patient's timeline: each event says what happened, who did it and when.
Detail of an audit event with the record version and the JSON of what changed, with IP and hash masked
Event detail: record version and what changed as JSON, with the IP and token hash masked ([redacted]).

Data redaction

Data that must not be disclosed or spread is masked in layers: in the audit trail, in logs, on screens, in API responses and in events sent to third parties.

  • A central redaction function is applied to every audit diff before it is written: it walks the object at any depth and replaces with [redacted] the credential fields (passwords, tokens, hashes, API keys, certificates, encrypted fields) and the personal-data fields (ID number, e-mail, IP, the patient's name, e-mail and phone, ad and visitor identifiers, user agent and room link).
  • The reason is the trail's own rule: since it is append-only, any personal data written there could never be erased (LGPD). The record ids are enough to follow the history.
  • When the list of protected fields grew, a migration applied the same redaction to the older audit rows: the trigger that protects the table is switched off only during that one-off cleanup and switched back on right after, even on error.
  • Logs and screens: e-mails become, for example, lu***@domain and the ID number is stored masked, with a keyed hash for lookups; link tokens (booking, invoice, setup) become [token] in logs, keys are never logged, and the payment provider key never appears on screen.
  • Who can see a patient's identity is a permission (patients:view-identity): without it, the person sees only the patient code, personal data is left out of the responses and searching by name, e-mail, phone or ID number is ignored; the code is derived from a keyed hash and cannot be traced back to the ID number.
  • A test walks every route of the read-only profile with real data and fails if any personal data shows up.
  • Third parties: no health or patient data goes into logs, URLs or ad events; hashed e-mail and phone are not sent to ad platforms without legal sign-off and specific consent; and in production the real message of unexpected errors is hidden.
auditoria   { "ip": "[redacted]", "refreshTokenHash": "[redacted]", "userId": "..." }
e-mail      lucas@clinica.com            ->  lu***@clinica.com
log (URL)   /agendamento/<token longo>   ->  /agendamento/[token]
paciente    sem patients:view-identity   ->  apenas o código P-XXXX-XXXX
Illustrative examples of the masking result.

Asaas integration

The patient pays via Pix, with automatic transfer to the professional (split) and a dedicated account for each one.

  • Each professional has a sub-account: each appointment's charge is created in it, with the split going to iPsiq's wallet.
  • The API client is thin and each call states which account it acts on (the main one or the sub-account); keys never go to logs.
  • The key is kept as a secret on AWS, read at run time and never shown on screen; the environment (sandbox or production) is a setting.
  • Webhooks are authenticated by token with a constant-time comparison, processed once per event id, and registered automatically on deploy and when each sub-account is created.
  • The charge carries the appointment as a reference to reconcile payments, and a full refund reverses the split.
  • The sub-account holder's data goes straight to Asaas, through a single-use link, and iPsiq does not store it.
  • What the patient can fix appears in the form; a configuration error shows a generic message and alerts the team with the exact reason, and the provider's technical message never reaches the patient.
  • Tests do not depend on the network: a local fake Asaas simulates the provider.

Screenshots from a development environment, with fictitious data.

Payment provider integration screen, with the key configured but not displayed and the webhook URL
Payment provider integration: the key is configured but never displayed; the webhook is registered automatically.

Settings

The platform's defaults and integrations in one place, searchable by screen or field; changes go through the same audit trail.

  • Example: the tolerance for starting an appointment, after which an unstarted appointment is refunded automatically.
  • The screens for commercial and tax rules are not shown here.

Screenshots from a development environment, with fictitious data.

Service setting with the tolerance minutes for starting an appointment
Settings: the tolerance rule for starting an appointment.