| name | sesion-mental-health-saas |
| description | Build and customize the Sesión mental health practice management platform with appointment scheduling, WhatsApp automation, AFIP billing, and Claude AI integration |
| triggers | ["how do I set up the Sesión mental health platform","integrate WhatsApp automation with Sesión","configure AFIP electronic billing in Sesión","set up Claude AI models in Sesión","build video consultation features in Sesión","customize appointment scheduling in Sesión","deploy Sesión for Argentine psychology clinics","integrate MercadoPago payments in Sesión"] |
Sesión Mental Health Practice Management Platform
Skill by ara.so — Devtools Skills collection.
Sesión is a comprehensive SaaS platform for psychology clinics and independent practitioners in Argentina. It orchestrates appointment scheduling, automated WhatsApp messaging, AFIP-compliant billing, secure video consultations, and AI-powered clinical assistance using Claude Opus 4.6 and Sonnet 4.6 models.
Architecture Overview
Sesión uses a microservices architecture built with:
- Backend: NestJS (TypeScript) for API orchestration
- Frontend: SvelteKit 5 with Tailwind CSS
- Database: PostgreSQL with Prisma ORM
- Messaging: Baileys for WhatsApp automation
- Video: LiveKit for WebRTC consultations
- AI: Anthropic Claude (Opus 4.6 + Sonnet 4.6)
- Payments: Stripe + MercadoPago
- Cache: Redis for session management
Installation
Prerequisites
node >= 18.0.0
postgresql >= 14.0
redis >= 6.0
Clone and Install
git clone https://github.com/fahad-hamid/psique-workflow-clinic.git
cd psique-workflow-clinic
npm install
cp .env.example .env
Environment Configuration
DATABASE_URL="postgresql://user:password@localhost:5432/sesion"
REDIS_URL="redis://localhost:6379"
ANTHROPIC_API_KEY="your_anthropic_key"
CLAUDE_OPUS_MODEL="claude-opus-4.6"
CLAUDE_SONNET_MODEL="claude-sonnet-4.6"
WHATSAPP_SESSION_PATH="./whatsapp-sessions"
WHATSAPP_WEBHOOK_SECRET="your_webhook_secret"
LIVEKIT_API_KEY="your_livekit_key"
LIVEKIT_API_SECRET="your_livekit_secret"
LIVEKIT_URL="wss://your-livekit-server.com"
STRIPE_SECRET_KEY="sk_test_..."
MERCADOPAGO_ACCESS_TOKEN="your_mp_token"
AFIP_CUIT="your_clinic_cuit"
AFIP_CERTIFICATE_PATH="./afip-cert.pem"
AFIP_PRIVATE_KEY_PATH="./afip-key.pem"
JWT_SECRET="your_jwt_secret"
APP_URL="http://localhost:3000"
Database Setup
npx prisma generate
npx prisma migrate deploy
npx prisma db seed
Start Development Server
npm run dev:backend
npm run dev:frontend
npm run dev
Prisma Schema Key Models
// prisma/schema.prisma
model Practitioner {
id String @id @default(uuid())
email String @unique
name String
fiscalCategory String // Monotributo, Responsable Inscripto
appointments Appointment[]
patients Patient[]
invoices Invoice[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Patient {
id String @id @default(uuid())
name String
email String?
phone String @unique
whatsappOptIn Boolean @default(false)
practitionerId String
practitioner Practitioner @relation(fields: [practitionerId], references: [id])
appointments Appointment[]
journeyStage String @default("primer_contacto")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Appointment {
id String @id @default(uuid())
practitionerId String
practitioner Practitioner @relation(fields: [practitionerId], references: [id])
patientId String
patient Patient @relation(fields: [patientId], references: [id])
startTime DateTime
endTime DateTime
sessionType String // presencial, virtual, evaluacion
status String @default("scheduled") // scheduled, confirmed, cancelled, completed
roomUrl String? // LiveKit room URL
invoiceId String? @unique
invoice Invoice? @relation(fields: [invoiceId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Invoice {
id String @id @default(uuid())
practitionerId String
practitioner Practitioner @relation(fields: [practitionerId], references: [id])
appointment Appointment?
invoiceNumber String @unique
afipCae String? // AFIP authorization code
amount Decimal
fiscalType String // A, B, C, M
paymentMethod String
paymentStatus String @default("pending")
issuedAt DateTime @default(now())
createdAt DateTime @default(now())
}
Appointment Scheduling API
Create Appointment
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { WhatsAppService } from '../whatsapp/whatsapp.service';
@Injectable()
export class AppointmentsService {
constructor(
private prisma: PrismaService,
private whatsapp: WhatsAppService,
) {}
async createAppointment(data: {
practitionerId: string;
patientId: string;
startTime: Date;
sessionType: 'presencial' | 'virtual' | 'evaluacion';
}) {
const conflict = await this.checkConflicts(
data.practitionerId,
data.startTime,
);
if (conflict) {
throw new Error('Time slot already occupied');
}
const endTime = (data..() + * );
appointment = ...({
: {
...data,
endTime,
: ,
},
: {
: ,
: ,
},
});
(appointment..) {
..(appointment);
}
appointment;
}
() {
endTime = (startTime.() + * );
...({
: {
practitionerId,
: { : },
: [
{
: { : startTime },
: { : startTime },
},
{
: { : endTime },
: { : endTime },
},
],
},
});
}
}
Appointment Controller
import { Controller, Post, Body, UseGuards } from '@nestjs/common';
import { AppointmentsService } from './appointments.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
@Controller('appointments')
@UseGuards(JwtAuthGuard)
export class AppointmentsController {
constructor(private appointmentsService: AppointmentsService) {}
@Post()
async create(@Body() createDto: CreateAppointmentDto) {
return this.appointmentsService.createAppointment(createDto);
}
}
WhatsApp Automation with Baileys
WhatsApp Service Setup
import { Injectable, OnModuleInit } from '@nestjs/common';
import makeWASocket, {
DisconnectReason,
useMultiFileAuthState,
WAMessage,
} from '@whiskeysockets/baileys';
import { Boom } from '@hapi/boom';
@Injectable()
export class WhatsAppService implements OnModuleInit {
private sock: any;
async onModuleInit() {
await this.connectToWhatsApp();
}
private async connectToWhatsApp() {
const { state, saveCreds } = await useMultiFileAuthState(
process.env.WHATSAPP_SESSION_PATH,
);
this.sock = makeWASocket({
auth: state,
printQRInTerminal: true,
});
this.sock.ev.on('connection.update', {
{ connection, lastDisconnect } = update;
(connection === ) {
shouldReconnect =
(lastDisconnect?. )?.?. !==
.;
(shouldReconnect) {
.();
}
}
});
...(, saveCreds);
...(, ..());
}
() {
message = .(appointment);
phoneNumber = .(appointment..);
..(phoneNumber, { : message });
}
() {
message = +
+
+
+
;
phoneNumber = .(appointment..);
..(phoneNumber, { : message });
}
() {
( msg messages) {
(!msg. || msg..) ;
text = msg..?.();
phone = msg..;
(text === || text === ) {
.(phone);
} (text === ) {
.(phone);
}
}
}
(: ): {
;
}
(: ): {
+
+
+
+
;
}
(: ): {
.(, {
: ,
: ,
: ,
: ,
: ,
: ,
}).(date);
}
}
Scheduled Reminder Jobs
import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
import { WhatsAppService } from '../whatsapp/whatsapp.service';
@Injectable()
export class ReminderScheduler {
constructor(
private prisma: PrismaService,
private whatsapp: WhatsAppService,
) {}
@Cron(CronExpression.EVERY_HOUR)
async send24HourReminders() {
const tomorrow = new Date();
tomorrow.setHours(tomorrow.getHours() + 24);
const appointments = await this.prisma.appointment.findMany({
where: {
startTime: {
gte: tomorrow,
: (tomorrow.() + * ),
},
: ,
: { : },
},
: { : , : },
});
( apt appointments) {
..(apt, );
}
}
(.)
() {
twoHoursFromNow = ();
twoHoursFromNow.(twoHoursFromNow.() + );
appointments = ...({
: {
: {
: twoHoursFromNow,
: (twoHoursFromNow.() + * ),
},
: ,
: { : },
},
: { : , : },
});
( apt appointments) {
..(apt, );
}
}
}
AFIP Electronic Billing Integration
AFIP Service
import { Injectable } from '@nestjs/common';
import { readFileSync } from 'fs';
import Afip from '@afipsdk/afip.js';
@Injectable()
export class AfipService {
private afip: any;
constructor() {
this.afip = new Afip({
CUIT: process.env.AFIP_CUIT,
cert: readFileSync(process.env.AFIP_CERTIFICATE_PATH),
key: readFileSync(process.env.AFIP_PRIVATE_KEY_PATH),
production: process.env.NODE_ENV === 'production',
});
}
async generateInvoice(data: {
fiscalType: 'A' | 'B' | 'C' | 'M';
amount: number;
patientCuit?: string;
concept: number; // 1=Productos, =Servicios, =Productos y Servicios
}) {
lastInvoice = ...(
,
data. === ? : data. === ? : ,
);
invoiceNumber = lastInvoice + ;
invoiceData = {
: ,
: ,
: data. === ? : data. === ? : ,
: data.,
: data. ? : ,
: data. || ,
: invoiceNumber,
: invoiceNumber,
: .( ()),
: data.,
: ,
: data. === ? data. / : data.,
: ,
: data. === ? data. - data. / : ,
: ,
: ,
: ,
};
(data. === ) {
invoiceData[] = [
{
: ,
: data. / ,
: data. - data. / ,
},
];
}
result = ...(invoiceData);
{
: ,
: result.,
: result.,
: result,
};
}
(: ): {
date.().()[].(, );
}
}
Invoice Service
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AfipService } from './afip.service';
import { WhatsAppService } from '../whatsapp/whatsapp.service';
@Injectable()
export class InvoiceService {
constructor(
private prisma: PrismaService,
private afip: AfipService,
private whatsapp: WhatsAppService,
) {}
async createInvoiceForAppointment(appointmentId: string) {
const appointment = await this.prisma.appointment.findUnique({
where: { id: appointmentId },
include: { practitioner: true, patient: true },
});
if (!appointment) ();
amount = ;
afipResult = ..({
: appointment.. === ? : ,
amount,
: ,
});
invoice = ...({
: {
: appointment.,
: afipResult.,
: afipResult.,
amount,
: appointment.. === ? : ,
: ,
: ,
},
});
...({
: { : appointmentId },
: { : invoice. },
});
(appointment..) {
.(invoice, appointment);
}
invoice;
}
() {
message = +
+
+
+
+
+
+
;
...(
..(appointment..),
{ : message },
);
}
}
Claude AI Integration
AI Orchestration Service
import { Injectable } from '@nestjs/common';
import Anthropic from '@anthropic-ai/sdk';
@Injectable()
export class ClaudeService {
private anthropic: Anthropic;
constructor() {
this.anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
});
}
async summarizeClinicalNotes(notes: string[], patientContext: string) {
const response = await this.anthropic.messages.create({
model: process.env.CLAUDE_OPUS_MODEL,
max_tokens: 2048,
temperature: 0.3,
system: `Sos un asistente especializado en psicología clínica en Argentina.
Tu tarea es resumir notas de sesiones terapéuticas de manera clara y estructurada,
respetando la confidencialidad y terminología profesional.`,
messages: [
{
: ,
: +
+
+
+
+
+
+
,
},
],
});
response.[]. === ? response.[]. : ;
}
() {
response = ...({
: process..,
: ,
: ,
: ,
: [
{
: ,
: ,
},
],
});
response.[]. === ? response.[]. : ;
}
() {
response = ...({
: process..,
: ,
: ,
: ,
: [
{
: ,
: .(patientHistory),
},
],
});
text = response.[]. === ? response.[]. : ;
.(text);
}
}
AI Controller with Model Routing
import { Controller, Post, Body, UseGuards } from '@nestjs/common';
import { ClaudeService } from './claude.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
@Controller('ai')
@UseGuards(JwtAuthGuard)
export class AIController {
constructor(private claude: ClaudeService) {}
@Post('summarize-notes')
async summarizeNotes(@Body() dto: { notes: string[]; patientId: string }) {
const patient = await this.getPatientContext(dto.patientId);
return this.claude.summarizeClinicalNotes(dto.notes, patient);
}
@Post('quick-assist')
async quickAssist() {
..(dto., dto.);
}
()
() {
history = .(dto.);
..(history);
}
}
LiveKit Video Consultation
Video Service
import { Injectable } from '@nestjs/common';
import { AccessToken } from 'livekit-server-sdk';
@Injectable()
export class LiveKitService {
async createRoomToken(
roomName: string,
participantName: string,
isPractitioner: boolean,
) {
const at = new AccessToken(
process.env.LIVEKIT_API_KEY,
process.env.LIVEKIT_API_SECRET,
{
identity: participantName,
},
);
at.addGrant({
roomJoin: true,
room: roomName,
canPublish: true,
canSubscribe: true,
canPublishData: isPractitioner,
});
return at.toJwt();
}
async generateSessionUrl(appointmentId: string, isPractitioner: boolean) {
const roomName = ;
participantName = isPractitioner ? : ;
token = .(
roomName,
participantName,
isPractitioner,
);
{
: ,
token,
: process..,
};
}
}
Video Component (SvelteKit)
<!-- frontend/src/routes/video/[roomName]/+page.svelte -->
<script lang="ts">
import { onMount } from 'svelte';
import { Room, RoomEvent } from 'livekit-client';
export let data; // { token, serverUrl }
let videoContainer: HTMLDivElement;
let room: Room;
onMount(async () => {
room = new Room({
adaptiveStream: true,
dynacast: true,
});
room.on(RoomEvent.TrackSubscribed, handleTrackSubscribed);
room.on(RoomEvent.Disconnected, handleDisconnect);
await room.connect(data.serverUrl, data.token);
// Publish local tracks
await room.localParticipant.enableCameraAndMicrophone();
});
function handleTrackSubscribed(track, publication, participant) {
if (track.kind === 'video' || track.kind === 'audio') {
const element = track.attach();
videoContainer.appendChild(element);
}
}
function handleDisconnect() {
console.log('Disconnected from room');
}
async function toggleMute() {
await room.localParticipant.setMicrophoneEnabled(
!room.localParticipant.isMicrophoneEnabled
);
}
async function toggleVideo() {
await room.localParticipant.setCameraEnabled(
!room.localParticipant.isCameraEnabled
);
}
</script>
<div class="video-consultation">
<div bind:this={videoContainer} class="video-grid"></div>
<div class="controls">
<button on:click={toggleMute} class="btn-control">
{room?.localParticipant.isMicrophoneEnabled ? '🎤' : '🔇'}
</button>
<button on:click={toggleVideo} class="btn-control">
{room?.localParticipant.isCameraEnabled ? '📹' : '🚫'}
</button>
</div>
</div>
<style>
.video-consultation {
height: 100vh;
display: flex;
flex-direction: column;
}
.video-grid {
flex: 1;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1rem;
padding: 1rem;
}
.controls {
display: flex;
justify-content: center;
gap: 1rem;
padding: 1rem;
background: #1f2937;
}
.btn-control {
width: 60px;
height: 60px;
border-radius: 50%;
font-size: 24px;
border: none;
background: #374151;
cursor: pointer;
}
</style>
Payment Integration
MercadoPago