iPsiq
Co-founder and software architect – 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, 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
- Tests and delivery
- Over 500 automated tests, continuous integration on every push and automatic AWS deploys.
- Versioned migrations
- 36 database migrations, versioned and reversible.
- Isolation proven by tests
- 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.
- Read-only profile
- 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 and limits
- 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 trail
- 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
- 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)
- Council registration
- 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.
- Specialty and RQE
- A medical specialty is only displayed with an RQE, following CFM rules; without an RQE the professional appears simply as "Doctor".
- Public label
- Each public profile's label follows CFM Resolution 2,336/2023, on medical advertising.
- Professional activation
- A professional only goes live on the site after completing the activation checklist and being activated by the team.
AWS infrastructure
- Secrets, email and photos
- Secrets in SSM Parameter Store, transactional e-mails in SES and photos in S3 with signed URLs.
- Backup and deploy
- 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.
- Temporary hold
- The chosen slot is held for a while, with protection against double booking.
- Payment at booking
- Payment is made via Pix at booking time, and the appointment is confirmed right away.
Screenshots from a development environment, with fictitious data.


Doctor area
Each professional signs in with their own login and sees only their own data: agenda, appointments, invoices and schedule.
- Isolation between professionals
- No professional can reach another's data, not even through the API: this is proven by integration tests in CI.
- Starting the appointment
- The appointment is started with the Start button, which is only enabled close to the scheduled time.
- Meet room
- 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.
- Hours and days off
- Weekly availability and time off; changes do not affect appointments already booked.
Screenshots from a development environment, with fictitious data.



Back-office
The team follows the agenda, appointments, doctors and schedules in one place.
- Search and filters
- Appointment search by name, e-mail, phone, ID number or patient code, with status and doctor filters.
- Quick actions on the agenda
- Quick agenda actions: complete, mark no-show, reschedule and cancel; rescheduling requires a reason, which goes to the audit trail.
- Command palette
- Command palette (Ctrl K) to jump straight to any screen or setting.
Screenshots from a development environment, with fictitious data.




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.
- Permission, role and person
- 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.
- Typed catalog
- 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
- 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.
- Granting rules
- 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.
- Extra permissions
- 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.
- Read-only profile
- 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.
- Permission-driven front end
- 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çãoScreenshots from a development environment, with fictitious data.


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
- 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.
- A global guard that fails closed
- 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.
- Permissions checked at compile time
- 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 only the key
- 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.
- Doctor from the session
- @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.
- Global exception filter
- 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.
- Tenant scope
- Every repository is tenant-scoped: the tenantId is part of every query and every update, which prevents access across contexts.
- Passwords and login attempts
- Passwords with Argon2 and a password policy, login-attempt lockout per account and a rate limiter on the login routes.
- Patterns enforced by tests
- 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
- Make check runs types, lint and tests inside Docker, and CI repeats it on every push.
The path of a request
Request
carries only the token, which is the session key
Global guard
loads the live session (role, permissions, doctor) and fails closed: a route that declares nothing is rejected
Access declared on the route
@Public()
open route
@Authenticated()
any signed-in person
@Permissions(...)
the role must grant all of them; names are checked at compile time
ZodValidationPipe
validates and transforms the body, with one message per invalid field
Handler
@CurrentUser() and @CurrentDoctorId() come from the session, never from the URL or the body
@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() { /* ... */ }
}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.
- BaseRepository
- 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.
- Failures recorded
- Failed writes are also recorded, with a failure result, outside the failed transaction.
- Only the changed fields
- 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.
- Version per record
- Each record has a version (1, 2, 3…) per entity, with "see this record's history" and "see everything from this person".
- Insert-only table
- The audit table is append-only: the database itself refuses UPDATE, DELETE and TRUNCATE (LGPD art. 37: the record of operations must be trustworthy).
- Sensitive data masked
- 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.
- No table is left out
- 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 in the team's language
- 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.
- A single timeline
- 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.
- Events linked to what they touch
- 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.
- Hide filter
- 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: a single write point
- 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.
How a write becomes history
Route or service
asks to create, update, delete or restore a record
BaseRepository
every repository inherits from it; on create it audits the saved row, on update the fields the caller states, and on delete the removed record
Same database transaction
Writes the change
to the business table
Writes the event (writeAuditEvent)
who, what, when and what changed, with sensitive data masked; idempotent by id
- both or neither
Audit table
insert-only: the database itself rejects UPDATE, DELETE and TRUNCATE. A failed write also leaves an event, with a failure result, outside the failed transaction.
- in the future
Event emission (outbox) (planned step)
swaps only writeAuditEvent and moves auditing to a microservice, without touching the repositories' code
Screenshots from a development environment, with fictitious data.




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.
- Central redaction
- 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).
- Why at write time
- 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.
- Old rows redacted
- 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 sees identity
- 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.
- Read-only profile test
- 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-XXXXAsaas integration
iPsiq integrates the Asaas API to take Pix payments with an automatic transfer to the professional (split): one sub-account per professional, Pix checkout, webhooks, invoices and subscriptions.
- Subaccounts
- Sub-accounts: each professional has one, opened by the team. The registration and bank details are filled in directly at Asaas, through a link with an expiry, and iPsiq does not store them; the professional only becomes active once Asaas approves the account, and the status arrives by webhook.
- Pix checkout
- Each appointment's charge is a checkout created in the professional's sub-account, with the split going to iPsiq's wallet and the appointment as the external reference; the checkout expiry follows the time slot's temporary hold.
- Two webhook doors
- Webhooks through two doors: the main account's (iPsiq's subscriptions and invoices) and one for each sub-account (that professional's appointments, invoices and approval). Each has its own token, compared in constant time; each sub-account's is derived (HMAC) from the master secret.
- A webhook is only a notice
- The webhook body is only a notice: the payment, invoice or account status is read again at Asaas, with the right account's key, before anything changes. A forged body cannot confirm a payment.
- Idempotency
- Each event is claimed atomically by its Asaas id before it is handled, so repeated or simultaneous deliveries do not repeat side effects (e-mails, video room, conversions). If handling fails, the claim is released and Asaas retries.
- Status reconciliation
- The payment state is applied to the appointment idempotently. A payment only updates appointments of its own sub-account's doctor, and a paid payment never goes back to failed because of a late event. The patient's "I already paid" asks Asaas directly (by checkout and by reference), rate-limited per appointment so it does not burn the API quota.
- Webhook registration
- The main account's is created or updated on every deploy, by an idempotent command, and each sub-account's is registered when it is created. The subscribed events cover payment, checkout, invoices and account status.
- Invoices
- The applicable NFS-e are issued through Asaas automatically once the payment is confirmed, with the status synchronized by webhook. Each invoice is only updated by the account that issued it, and downloaded files have a size limit.
- Subscriptions
- The professionals' subscriptions and recurring charges run through the main account and are updated by its webhooks.
- API client
- Thin, each call states which account it acts on (the main one or a sub-account), has a 15 s timeout so a slow Asaas cannot hold requests, and never logs the keys.
- Secrets
- 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.
- Errors
- 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.
- Network-free tests
- An HTTP server that imitates Asaas records every call and answers as the test defines, so the integration is verified without the provider.
From payment to webhook
Patient books and pays by Pix
iPsiq creates the checkout
in the professional's sub-account, with the appointment as reference and the split to the iPsiq wallet
Asaas
processes the payment, makes the automatic transfer and notifies by webhook
- notice
Asaas webhook, three checks
1. Token
constant-time comparison
2. Event id
claimed atomically, handled exactly once
3. Read at Asaas
the payment is read again from the API, with the right account's key, before the appointment changes
Appointment updated
and the provider notice enters the audit timeline
Screenshots from a development environment, with fictitious data.

Settings
The platform's defaults and integrations in one place, searchable by screen or field; changes go through the same audit trail.
- Screens by theme
- Screens are grouped by theme (appointments, finance and integrations), and every change goes into the audit trail.
- What is not shown
- The screens for commercial and tax rules are not shown here.
Screenshots from a development environment, with fictitious data.
