diff --git a/backend/drizzle/0015_polite_captain_marvel.sql b/backend/drizzle/0015_polite_captain_marvel.sql new file mode 100644 index 0000000..1015feb --- /dev/null +++ b/backend/drizzle/0015_polite_captain_marvel.sql @@ -0,0 +1,23 @@ +CREATE TABLE "invoices" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" text NOT NULL, + "patient_file_number" text DEFAULT '' NOT NULL, + "patient_name" text NOT NULL, + "patient_initials" text NOT NULL, + "number" text NOT NULL, + "issued_at" date DEFAULT now() NOT NULL, + "due_at" date, + "status" text NOT NULL, + "line_items" jsonb NOT NULL, + "installments" jsonb NOT NULL, + "notes" text, + "source" text DEFAULT 'manual' NOT NULL, + "created_by" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "invoices" ADD CONSTRAINT "invoices_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "invoices" ADD CONSTRAINT "invoices_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "invoices_org_idx" ON "invoices" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "invoices_org_file_idx" ON "invoices" USING btree ("organization_id","patient_file_number"); \ No newline at end of file diff --git a/backend/drizzle/meta/0015_snapshot.json b/backend/drizzle/meta/0015_snapshot.json new file mode 100644 index 0000000..40b5049 --- /dev/null +++ b/backend/drizzle/meta/0015_snapshot.json @@ -0,0 +1,2977 @@ +{ + "id": "c2d24a1e-f929-4ee6-a252-a0cbba19e961", + "prevId": "5503626a-6d81-4761-8862-781549184613", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitation_organizationId_idx": { + "name": "invitation_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "organization_slug_uidx": { + "name": "organization_slug_uidx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit": { + "name": "rate_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "last_request": { + "name": "last_request", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "rate_limit_key_unique": { + "name": "rate_limit_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_username": { + "name": "display_username", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "user_username_unique": { + "name": "user_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.patient_allergies": { + "name": "patient_allergies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "patient_id": { + "name": "patient_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "substance": { + "name": "substance", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reaction": { + "name": "reaction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "allergies_patient_idx": { + "name": "allergies_patient_idx", + "columns": [ + { + "expression": "patient_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "patient_allergies_patient_id_patients_id_fk": { + "name": "patient_allergies_patient_id_patients_id_fk", + "tableFrom": "patient_allergies", + "tableTo": "patients", + "columnsFrom": [ + "patient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.patient_encounters": { + "name": "patient_encounters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "patient_id": { + "name": "patient_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "encounters_patient_idx": { + "name": "encounters_patient_idx", + "columns": [ + { + "expression": "patient_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "patient_encounters_patient_id_patients_id_fk": { + "name": "patient_encounters_patient_id_patients_id_fk", + "tableFrom": "patient_encounters", + "tableTo": "patients", + "columnsFrom": [ + "patient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.patient_labs": { + "name": "patient_labs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "patient_id": { + "name": "patient_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "flag": { + "name": "flag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "taken_at": { + "name": "taken_at", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "labs_patient_idx": { + "name": "labs_patient_idx", + "columns": [ + { + "expression": "patient_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "patient_labs_patient_id_patients_id_fk": { + "name": "patient_labs_patient_id_patients_id_fk", + "tableFrom": "patient_labs", + "tableTo": "patients", + "columnsFrom": [ + "patient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.patient_medications": { + "name": "patient_medications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "patient_id": { + "name": "patient_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dose": { + "name": "dose", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "frequency": { + "name": "frequency", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "medications_patient_idx": { + "name": "medications_patient_idx", + "columns": [ + { + "expression": "patient_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "patient_medications_patient_id_patients_id_fk": { + "name": "patient_medications_patient_id_patients_id_fk", + "tableFrom": "patient_medications", + "tableTo": "patients", + "columnsFrom": [ + "patient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.patients": { + "name": "patients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_number": { + "name": "file_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "age": { + "name": "age", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sex": { + "name": "sex", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pcp": { + "name": "pcp", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "initials": { + "name": "initials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "alerts": { + "name": "alerts", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "vitals_bp": { + "name": "vitals_bp", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vitals_hr": { + "name": "vitals_hr", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vitals_temp": { + "name": "vitals_temp", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vitals_spo2": { + "name": "vitals_spo2", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vitals_taken_at": { + "name": "vitals_taken_at", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vitals_trend": { + "name": "vitals_trend", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "lab_trend": { + "name": "lab_trend", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "primary_provider_id": { + "name": "primary_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "patients_org_file_uidx": { + "name": "patients_org_file_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "patients_org_idx": { + "name": "patients_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "patients_organization_id_organization_id_fk": { + "name": "patients_organization_id_organization_id_fk", + "tableFrom": "patients", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "patients_primary_provider_id_user_id_fk": { + "name": "patients_primary_provider_id_user_id_fk", + "tableFrom": "patients", + "tableTo": "user", + "columnsFrom": [ + "primary_provider_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "patients_created_by_user_id_fk": { + "name": "patients_created_by_user_id_fk", + "tableFrom": "patients", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.patient_problems": { + "name": "patient_problems", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "patient_id": { + "name": "patient_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "since": { + "name": "since", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "problems_patient_idx": { + "name": "problems_patient_idx", + "columns": [ + { + "expression": "patient_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "patient_problems_patient_id_patients_id_fk": { + "name": "patient_problems_patient_id_patients_id_fk", + "tableFrom": "patient_problems", + "tableTo": "patients", + "columnsFrom": [ + "patient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notes": { + "name": "notes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notes_org_author_idx": { + "name": "notes_org_author_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notes_organization_id_organization_id_fk": { + "name": "notes_organization_id_organization_id_fk", + "tableFrom": "notes", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notes_author_id_user_id_fk": { + "name": "notes_author_id_user_id_fk", + "tableFrom": "notes", + "tableTo": "user", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appointments": { + "name": "appointments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "patient_file_number": { + "name": "patient_file_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "patient_name": { + "name": "patient_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "patient_initials": { + "name": "patient_initials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "time": { + "name": "time", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "appointments_org_date_idx": { + "name": "appointments_org_date_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "appointments_organization_id_organization_id_fk": { + "name": "appointments_organization_id_organization_id_fk", + "tableFrom": "appointments", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "appointments_created_by_user_id_fk": { + "name": "appointments_created_by_user_id_fk", + "tableFrom": "appointments", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.prescriptions": { + "name": "prescriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "patient_file_number": { + "name": "patient_file_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "patient_name": { + "name": "patient_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "patient_initials": { + "name": "patient_initials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "medication": { + "name": "medication", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dose": { + "name": "dose", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "frequency": { + "name": "frequency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prescriber": { + "name": "prescriber", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prescribed_at": { + "name": "prescribed_at", + "type": "date", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "duration": { + "name": "duration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "prescriptions_org_idx": { + "name": "prescriptions_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "prescriptions_organization_id_organization_id_fk": { + "name": "prescriptions_organization_id_organization_id_fk", + "tableFrom": "prescriptions", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "prescriptions_created_by_user_id_fk": { + "name": "prescriptions_created_by_user_id_fk", + "tableFrom": "prescriptions", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoices": { + "name": "invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "patient_file_number": { + "name": "patient_file_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "patient_name": { + "name": "patient_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "patient_initials": { + "name": "patient_initials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issued_at": { + "name": "issued_at", + "type": "date", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "due_at": { + "name": "due_at", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "line_items": { + "name": "line_items", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "installments": { + "name": "installments", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invoices_org_idx": { + "name": "invoices_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invoices_org_file_idx": { + "name": "invoices_org_file_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "patient_file_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoices_organization_id_organization_id_fk": { + "name": "invoices_organization_id_organization_id_fk", + "tableFrom": "invoices", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invoices_created_by_user_id_fk": { + "name": "invoices_created_by_user_id_fk", + "tableFrom": "invoices", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.inventory": { + "name": "inventory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "form": { + "name": "form", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "strength": { + "name": "strength", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "stock_quantity": { + "name": "stock_quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reorder_threshold": { + "name": "reorder_threshold", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "expires_at": { + "name": "expires_at", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inventory_org_idx": { + "name": "inventory_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "inventory_organization_id_organization_id_fk": { + "name": "inventory_organization_id_organization_id_fk", + "tableFrom": "inventory", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "inventory_created_by_user_id_fk": { + "name": "inventory_created_by_user_id_fk", + "tableFrom": "inventory", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assignee": { + "name": "assignee", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Unassigned'" + }, + "assignee_role": { + "name": "assignee_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "due": { + "name": "due", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'No due date'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "patient": { + "name": "patient", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "done": { + "name": "done", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_name": { + "name": "created_by_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tasks_org_idx": { + "name": "tasks_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_organization_id_organization_id_fk": { + "name": "tasks_organization_id_organization_id_fk", + "tableFrom": "tasks", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_created_by_user_id_fk": { + "name": "tasks_created_by_user_id_fk", + "tableFrom": "tasks", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.activity_log": { + "name": "activity_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "patient_name": { + "name": "patient_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "patient_file_number": { + "name": "patient_file_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "activity_org_created_idx": { + "name": "activity_org_created_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "activity_log_organization_id_organization_id_fk": { + "name": "activity_log_organization_id_organization_id_fk", + "tableFrom": "activity_log", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "activity_log_actor_id_user_id_fk": { + "name": "activity_log_actor_id_user_id_fk", + "tableFrom": "activity_log", + "tableTo": "user", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.conversation_participants": { + "name": "conversation_participants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "conv_participant_uidx": { + "name": "conv_participant_uidx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conv_participant_user_idx": { + "name": "conv_participant_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversation_participants_conversation_id_conversations_id_fk": { + "name": "conversation_participants_conversation_id_conversations_id_fk", + "tableFrom": "conversation_participants", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversation_participants_user_id_user_id_fk": { + "name": "conversation_participants_user_id_user_id_fk", + "tableFrom": "conversation_participants", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.conversations": { + "name": "conversations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_group": { + "name": "is_group", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversations_org_idx": { + "name": "conversations_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversations_organization_id_organization_id_fk": { + "name": "conversations_organization_id_organization_id_fk", + "tableFrom": "conversations", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversations_created_by_user_id_fk": { + "name": "conversations_created_by_user_id_fk", + "tableFrom": "conversations", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sender_id": { + "name": "sender_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "messages_conv_idx": { + "name": "messages_conv_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "messages_conversation_id_conversations_id_fk": { + "name": "messages_conversation_id_conversations_id_fk", + "tableFrom": "messages", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "messages_sender_id_user_id_fk": { + "name": "messages_sender_id_user_id_fk", + "tableFrom": "messages", + "tableTo": "user", + "columnsFrom": [ + "sender_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "read": { + "name": "read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_initials": { + "name": "actor_initials", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notifications_org_user_read_idx": { + "name": "notifications_org_user_read_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "read", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notifications_organization_id_organization_id_fk": { + "name": "notifications_organization_id_organization_id_fk", + "tableFrom": "notifications", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_user_id_user_id_fk": { + "name": "notifications_user_id_user_id_fk", + "tableFrom": "notifications", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_settings": { + "name": "user_settings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "preferences": { + "name": "preferences", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_settings_user_id_user_id_fk": { + "name": "user_settings_user_id_user_id_fk", + "tableFrom": "user_settings", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_ai_settings": { + "name": "user_ai_settings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local'" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'anthropic'" + }, + "ollama_base_url": { + "name": "ollama_base_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'http://localhost:11434'" + }, + "ollama_model": { + "name": "ollama_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'llama3.1'" + }, + "default_model": { + "name": "default_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-sonnet-4-6'" + }, + "default_effort": { + "name": "default_effort", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "veil_level": { + "name": "veil_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "api_keys_cipher": { + "name": "api_keys_cipher", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_ai_settings_user_id_user_id_fk": { + "name": "user_ai_settings_user_id_user_id_fk", + "tableFrom": "user_ai_settings", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/backend/drizzle/meta/_journal.json b/backend/drizzle/meta/_journal.json index bbd4f09..2a6249a 100644 --- a/backend/drizzle/meta/_journal.json +++ b/backend/drizzle/meta/_journal.json @@ -106,6 +106,13 @@ "when": 1781454115877, "tag": "0014_youthful_aqueduct", "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1781455848772, + "tag": "0015_polite_captain_marvel", + "breakpoints": true } ] } \ No newline at end of file diff --git a/backend/src/db/schema/index.ts b/backend/src/db/schema/index.ts index 9936324..c059844 100644 --- a/backend/src/db/schema/index.ts +++ b/backend/src/db/schema/index.ts @@ -3,6 +3,7 @@ export * from "./patients.js"; export * from "./notes.js"; export * from "./appointments.js"; export * from "./prescriptions.js"; +export * from "./invoices.js"; export * from "./inventory.js"; export * from "./tasks.js"; export * from "./activity.js"; diff --git a/backend/src/db/schema/invoices.ts b/backend/src/db/schema/invoices.ts new file mode 100644 index 0000000..4dcf92e --- /dev/null +++ b/backend/src/db/schema/invoices.ts @@ -0,0 +1,55 @@ +import { + date, + index, + jsonb, + pgTable, + text, + timestamp, + uuid, +} from "drizzle-orm/pg-core"; + +import type { + InvoiceInstallment, + InvoiceLineItem, + InvoiceStatus, +} from "../../types/invoice.js"; +import { organization, user } from "./auth.js"; + +// One row per patient invoice, scoped to a clinic (organization). Patient +// identity is denormalized (name/initials/file number) so the ledger renders +// without joining. Line items and installments are stored as JSONB. `issuedAt` +// defaults to today. +export const invoices = pgTable( + "invoices", + { + id: uuid("id").primaryKey().defaultRandom(), + organizationId: text("organization_id") + .notNull() + .references(() => organization.id, { onDelete: "cascade" }), + patientFileNumber: text("patient_file_number").notNull().default(""), + patientName: text("patient_name").notNull(), + patientInitials: text("patient_initials").notNull(), + number: text("number").notNull(), + issuedAt: date("issued_at").defaultNow().notNull(), + dueAt: date("due_at"), + status: text("status").$type().notNull(), + lineItems: jsonb("line_items").$type().notNull(), + installments: jsonb("installments") + .$type() + .notNull(), + notes: text("notes"), + source: text("source").$type<"manual" | "ai">().notNull().default("manual"), + createdBy: text("created_by").references(() => user.id, { + onDelete: "set null", + }), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => new Date()) + .notNull(), + }, + (t) => [ + index("invoices_org_idx").on(t.organizationId), + index("invoices_org_file_idx").on(t.organizationId, t.patientFileNumber), + ], +); diff --git a/backend/src/index.ts b/backend/src/index.ts index d59d77f..6b8a6cb 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -15,6 +15,7 @@ import { appointmentsRouter } from "./routes/appointments.js"; import { chatRouter } from "./routes/chat.js"; import { conversationsRouter } from "./routes/conversations.js"; import { inventoryRouter } from "./routes/inventory.js"; +import { invoicesRouter } from "./routes/invoices.js"; import { notesRouter } from "./routes/notes.js"; import { notificationsRouter } from "./routes/notifications.js"; import { patientsRouter } from "./routes/patients.js"; @@ -63,6 +64,7 @@ app.use("/api/notes", notesRouter); app.use("/api/appointments", appointmentsRouter); app.use("/api/prescriptions", prescriptionsRouter); app.use("/api/inventory", inventoryRouter); +app.use("/api/invoices", invoicesRouter); app.use("/api/tasks", tasksRouter); app.use("/api/staff", staffRouter); app.use("/api/activity", activityRouter); diff --git a/backend/src/lib/access.ts b/backend/src/lib/access.ts index 738375f..c802856 100644 --- a/backend/src/lib/access.ts +++ b/backend/src/lib/access.ts @@ -17,6 +17,7 @@ export const statements = { patient: ["read", "write", "delete"], appointment: ["read", "write", "delete"], prescription: ["read", "write", "delete"], + invoice: ["read", "write", "delete"], inventory: ["read", "write", "delete"], task: ["read", "write", "delete"], lab: ["read", "write"], @@ -40,6 +41,7 @@ export const owner = ac.newRole({ patient: ["read", "write", "delete"], appointment: ["read", "write", "delete"], prescription: ["read", "write", "delete"], + invoice: ["read", "write", "delete"], inventory: ["read", "write", "delete"], task: ["read", "write", "delete"], lab: ["read", "write"], @@ -50,6 +52,7 @@ export const admin = ac.newRole({ patient: ["read", "write", "delete"], appointment: ["read", "write", "delete"], prescription: ["read", "write", "delete"], + invoice: ["read", "write", "delete"], inventory: ["read", "write", "delete"], task: ["read", "write", "delete"], lab: ["read", "write"], @@ -61,6 +64,7 @@ export const member = ac.newRole({ patient: ["read", "write"], appointment: ["read", "write", "delete"], prescription: ["read", "write", "delete"], + invoice: ["read", "write", "delete"], inventory: ["read", "write", "delete"], task: ["read", "write", "delete"], lab: ["read", "write"], @@ -74,6 +78,7 @@ export const doctor = ac.newRole({ patient: ["read", "write"], appointment: ["read", "write", "delete"], prescription: ["read", "write", "delete"], + invoice: ["read", "write", "delete"], inventory: ["read", "write", "delete"], task: ["read", "write", "delete"], lab: ["read", "write"], @@ -88,6 +93,7 @@ export const reception = ac.newRole({ ...memberAc.statements, patient: ["read", "write"], appointment: ["read", "write", "delete"], + invoice: ["read", "write", "delete"], task: ["read", "write"], }); diff --git a/backend/src/lib/invoice-validation.ts b/backend/src/lib/invoice-validation.ts new file mode 100644 index 0000000..59ba841 --- /dev/null +++ b/backend/src/lib/invoice-validation.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; + +import { initialsFromName } from "./initials.js"; + +const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD."); + +export const invoiceLineItemSchema = z.object({ + description: z.string().trim().min(1, "Description is required.").max(300), + quantity: z.coerce.number().min(0).max(100_000).default(1), + unitPrice: z.coerce.number().min(0).max(10_000_000).default(0), +}); + +export const invoiceInstallmentSchema = z.object({ + label: z.string().trim().max(120).default(""), + amount: z.coerce.number().min(0).max(10_000_000).default(0), + dueAt: isoDate.nullable().default(null), + paid: z.boolean().default(false), +}); + +// Payload accepted by POST/PUT /api/invoices. `number` and `issuedAt` are filled +// server-side on create when omitted; initials are derived from the name. +export const invoiceInputSchema = z + .object({ + fileNumber: z.string().trim().default(""), + name: z.string().trim().min(1, "Patient name is required.").max(200), + initials: z.string().trim().max(4).default(""), + number: z.string().trim().max(60).optional(), + issuedAt: isoDate.optional(), + dueAt: isoDate.nullish(), + status: z.enum(["draft", "sent", "paid", "void"]).default("draft"), + lineItems: z.array(invoiceLineItemSchema).default([]), + installments: z.array(invoiceInstallmentSchema).default([]), + notes: z.string().max(5000).nullish(), + source: z.enum(["manual", "ai"]).default("manual"), + }) + .transform((v) => ({ + ...v, + initials: v.initials || initialsFromName(v.name), + })); + +export type InvoiceInput = z.infer; diff --git a/backend/src/routes/invoices.ts b/backend/src/routes/invoices.ts new file mode 100644 index 0000000..9ae81eb --- /dev/null +++ b/backend/src/routes/invoices.ts @@ -0,0 +1,138 @@ +import { Router } from "express"; +import { z } from "zod"; + +import { HttpError } from "../lib/http-error.js"; +import { invoiceInputSchema } from "../lib/invoice-validation.js"; +import { + requireAuth, + requireOrg, + requirePermission, +} from "../middleware/auth.js"; +import { recordActivity } from "../services/activity.js"; +import * as service from "../services/invoices.js"; + +export const invoicesRouter = Router(); + +// Invoices are clinic-wide billing records, gated by the caller's role. +invoicesRouter.use(requireAuth, requireOrg); + +invoicesRouter.get( + "/", + requirePermission({ invoice: ["read"] }), + async (req, res, next) => { + try { + res.json(await service.listInvoices(req.organizationId!)); + } catch (err) { + next(err); + } + }, +); + +invoicesRouter.post( + "/", + requirePermission({ invoice: ["write"] }), + async (req, res, next) => { + try { + const input = invoiceInputSchema.parse(req.body); + const created = await service.createInvoice( + req.organizationId!, + req.user!.id, + input, + ); + await recordActivity({ + orgId: req.organizationId!, + actor: { id: req.user!.id, name: req.user!.name }, + action: `Created invoice ${created.number} for ${created.name}`, + entityType: "invoice", + entityId: created.id, + patientName: created.name, + patientFileNumber: created.fileNumber || null, + }); + res.status(201).json(created); + } catch (err) { + next(err); + } + }, +); + +invoicesRouter.put( + "/:id", + requirePermission({ invoice: ["write"] }), + async (req, res, next) => { + try { + const input = invoiceInputSchema.parse(req.body); + const updated = await service.updateInvoice( + req.organizationId!, + req.params.id as string, + input, + ); + if (!updated) throw new HttpError(404, "Invoice not found."); + await recordActivity({ + orgId: req.organizationId!, + actor: { id: req.user!.id, name: req.user!.name }, + action: `Updated invoice ${updated.number}`, + entityType: "invoice", + entityId: updated.id, + patientName: updated.name, + patientFileNumber: updated.fileNumber || null, + }); + res.json(updated); + } catch (err) { + next(err); + } + }, +); + +const splitSchema = z.object({ count: z.coerce.number().int().min(1).max(36) }); + +invoicesRouter.post( + "/:id/split", + requirePermission({ invoice: ["write"] }), + async (req, res, next) => { + try { + const { count } = splitSchema.parse(req.body); + const updated = await service.splitIntoInstallments( + req.organizationId!, + req.params.id as string, + count, + ); + if (!updated) throw new HttpError(404, "Invoice not found."); + await recordActivity({ + orgId: req.organizationId!, + actor: { id: req.user!.id, name: req.user!.name }, + action: `Split invoice ${updated.number} into ${count} installments`, + entityType: "invoice", + entityId: updated.id, + patientName: updated.name, + patientFileNumber: updated.fileNumber || null, + }); + res.json(updated); + } catch (err) { + next(err); + } + }, +); + +invoicesRouter.delete( + "/:id", + requirePermission({ invoice: ["delete"] }), + async (req, res, next) => { + try { + const ok = await service.deleteInvoice( + req.organizationId!, + req.params.id as string, + ); + if (!ok) throw new HttpError(404, "Invoice not found."); + await recordActivity({ + orgId: req.organizationId!, + actor: { id: req.user!.id, name: req.user!.name }, + action: "Deleted invoice", + entityType: "invoice", + entityId: req.params.id as string, + }); + res.status(204).end(); + } catch (err) { + next(err); + } + }, +); diff --git a/backend/src/services/invoices.ts b/backend/src/services/invoices.ts new file mode 100644 index 0000000..2fc668a --- /dev/null +++ b/backend/src/services/invoices.ts @@ -0,0 +1,171 @@ +import { and, desc, eq, sql } from "drizzle-orm"; + +import { db } from "../db/index.js"; +import { invoices } from "../db/schema/invoices.js"; +import type { InvoiceInput } from "../lib/invoice-validation.js"; +import type { Invoice, InvoiceInstallment } from "../types/invoice.js"; + +type InvoiceRow = typeof invoices.$inferSelect; + +// Postgres throws on a malformed uuid; treat non-uuid ids as "not found". +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function toInvoice(row: InvoiceRow): Invoice { + return { + id: row.id, + fileNumber: row.patientFileNumber, + name: row.patientName, + initials: row.patientInitials, + number: row.number, + issuedAt: row.issuedAt, + dueAt: row.dueAt, + status: row.status, + lineItems: row.lineItems, + installments: row.installments, + notes: row.notes, + source: row.source, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} + +function columns(orgId: string, input: InvoiceInput, createdBy?: string) { + return { + organizationId: orgId, + patientFileNumber: input.fileNumber, + patientName: input.name, + patientInitials: input.initials, + number: input.number ?? "", + status: input.status, + lineItems: input.lineItems, + installments: input.installments, + notes: input.notes ?? null, + source: input.source, + ...(input.issuedAt ? { issuedAt: input.issuedAt } : {}), + dueAt: input.dueAt ?? null, + ...(createdBy ? { createdBy } : {}), + }; +} + +export function invoiceTotal(input: { + lineItems: { quantity: number; unitPrice: number }[]; +}): number { + return input.lineItems.reduce( + (sum, li) => sum + li.quantity * li.unitPrice, + 0, + ); +} + +// Next human invoice number for an org: "INV-" + (max existing numeric suffix + +// 1, floored at 1000). Falls back cleanly when there are no prior invoices. +async function generateInvoiceNumber(orgId: string): Promise { + const [r] = await db + .select({ + max: sql`coalesce(max(nullif(regexp_replace(${invoices.number}, '\\D', '', 'g'), '')::bigint), 999)`, + }) + .from(invoices) + .where(eq(invoices.organizationId, orgId)); + return `INV-${Number(r?.max ?? 999) + 1}`; +} + +// Add `months` calendar months to an ISO (YYYY-MM-DD) date, returning an ISO +// date. Used to stagger installment due dates from the issue date. +function addMonths(iso: string, months: number): string { + const [y, m, d] = iso.split("-").map(Number); + const base = new Date(Date.UTC(y!, (m! - 1) + months, d!)); + return base.toISOString().slice(0, 10); +} + +export async function listInvoices(orgId: string): Promise { + const rows = await db + .select() + .from(invoices) + .where(eq(invoices.organizationId, orgId)) + .orderBy(desc(invoices.issuedAt), desc(invoices.createdAt)); + return rows.map(toInvoice); +} + +export async function getInvoice( + orgId: string, + id: string, +): Promise { + if (!UUID_RE.test(id)) return null; + const [row] = await db + .select() + .from(invoices) + .where(and(eq(invoices.id, id), eq(invoices.organizationId, orgId))); + return row ? toInvoice(row) : null; +} + +export async function createInvoice( + orgId: string, + userId: string, + input: InvoiceInput, +): Promise { + const number = input.number || (await generateInvoiceNumber(orgId)); + const [row] = await db + .insert(invoices) + .values(columns(orgId, { ...input, number }, userId)) + .returning(); + return toInvoice(row!); +} + +export async function updateInvoice( + orgId: string, + id: string, + input: InvoiceInput, +): Promise { + if (!UUID_RE.test(id)) return null; + const [row] = await db + .update(invoices) + .set(columns(orgId, input)) + .where(and(eq(invoices.id, id), eq(invoices.organizationId, orgId))) + .returning(); + return row ? toInvoice(row) : null; +} + +export async function deleteInvoice( + orgId: string, + id: string, +): Promise { + if (!UUID_RE.test(id)) return false; + const deleted = await db + .delete(invoices) + .where(and(eq(invoices.id, id), eq(invoices.organizationId, orgId))) + .returning({ id: invoices.id }); + return deleted.length > 0; +} + +// Split an invoice's total into `count` roughly-equal installments, staggered +// one month apart from the issue date. Amounts are computed in cents so they +// always sum back to the exact total (any remainder lands on the first slice). +export async function splitIntoInstallments( + orgId: string, + id: string, + count: number, +): Promise { + const invoice = await getInvoice(orgId, id); + if (!invoice) return null; + const n = Math.max(1, Math.min(36, Math.floor(count))); + const totalCents = Math.round(invoiceTotal(invoice) * 100); + const base = Math.floor(totalCents / n); + const remainder = totalCents - base * n; + + const installments: InvoiceInstallment[] = Array.from( + { length: n }, + (_, i) => ({ + label: `${i + 1} of ${n}`, + amount: (base + (i < remainder ? 1 : 0)) / 100, + dueAt: addMonths(invoice.issuedAt, i), + paid: false, + }), + ); + + const [row] = await db + .update(invoices) + .set({ installments }) + .where(and(eq(invoices.id, id), eq(invoices.organizationId, orgId))) + .returning(); + return row ? toInvoice(row) : null; +} diff --git a/backend/src/types/activity.ts b/backend/src/types/activity.ts index 531528b..8dba34e 100644 --- a/backend/src/types/activity.ts +++ b/backend/src/types/activity.ts @@ -6,6 +6,7 @@ export type ActivityEntityType = | "note" | "appointment" | "prescription" + | "invoice" | "inventory" | "task"; diff --git a/backend/src/types/invoice.ts b/backend/src/types/invoice.ts new file mode 100644 index 0000000..ab13d8d --- /dev/null +++ b/backend/src/types/invoice.ts @@ -0,0 +1,36 @@ +// The canonical Invoice shape returned by the API. Mirrors the frontend +// `lib/invoices.ts` Invoice type. Scoped to the active clinic; patient fields +// are denormalized for display and `fileNumber` links to a patient record. +export type InvoiceStatus = "draft" | "sent" | "paid" | "void"; + +export type InvoiceLineItem = { + description: string; + quantity: number; + unitPrice: number; +}; + +// One slice of a split bill. `amount` is the installment total; `dueAt` is an +// optional ISO date; `paid` tracks settlement. +export type InvoiceInstallment = { + label: string; + amount: number; + dueAt: string | null; + paid: boolean; +}; + +export type Invoice = { + id: string; + fileNumber: string; + name: string; + initials: string; + number: string; // human invoice number, e.g. "INV-1001" + issuedAt: string; // YYYY-MM-DD + dueAt: string | null; // YYYY-MM-DD + status: InvoiceStatus; + lineItems: InvoiceLineItem[]; + installments: InvoiceInstallment[]; + notes: string | null; + source: "manual" | "ai"; + createdAt: string; + updatedAt: string; +}; diff --git a/frontend/app/(app)/invoices/page.tsx b/frontend/app/(app)/invoices/page.tsx new file mode 100644 index 0000000..bd6f90c --- /dev/null +++ b/frontend/app/(app)/invoices/page.tsx @@ -0,0 +1,10 @@ +import { InvoicesView } from "@/components/invoices/invoices-view"; +import { SidebarInset } from "@/components/ui/sidebar"; + +export default function InvoicesPage() { + return ( + + + + ); +} diff --git a/frontend/components/invoices/invoice-detail-sheet.tsx b/frontend/components/invoices/invoice-detail-sheet.tsx new file mode 100644 index 0000000..c7a063e --- /dev/null +++ b/frontend/components/invoices/invoice-detail-sheet.tsx @@ -0,0 +1,277 @@ +"use client"; + +import { Download, Pencil, Split, Trash2 } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { AiBadge } from "@/components/ai-badge"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Sheet, + SheetFooter, + SheetHeader, + SheetPanel, + SheetPopup, + SheetTitle, +} from "@/components/ui/sheet"; +import { downloadInvoicePdf } from "@/lib/invoice-pdf"; +import { + deleteInvoice, + formatInvoiceDate, + formatMoney, + type Invoice, + type InvoiceStatus, + invoiceTotal, + splitInvoice, +} from "@/lib/invoices"; +import { notify } from "@/lib/toast"; + +const statusVariant: Record< + InvoiceStatus, + "default" | "secondary" | "destructive" | "outline" +> = { + draft: "secondary", + sent: "default", + paid: "outline", + void: "destructive", +}; + +export function InvoiceDetailSheet({ + invoice, + open, + onOpenChange, + onChanged, + onDeleted, + onEdit, +}: { + invoice: Invoice | null; + open: boolean; + onOpenChange: (open: boolean) => void; + onChanged: (invoice: Invoice) => void; + onDeleted: (id: string) => void; + onEdit: (invoice: Invoice) => void; +}) { + const { t } = useTranslation(); + const [count, setCount] = useState(3); + const [busy, setBusy] = useState(false); + + useEffect(() => { + setCount(3); + }, [invoice?.id]); + + if (!invoice) { + return ( + + + + {t("invoices.sheet.fallbackTitle")} + + + + ); + } + + const total = invoiceTotal(invoice); + + const split = async () => { + setBusy(true); + try { + const updated = await splitInvoice(invoice.id, count); + onChanged(updated); + notify.success( + t("invoices.sheet.splitTitle"), + `${invoice.number} · ${count}`, + ); + } catch { + notify.error( + t("invoices.sheet.splitFailedTitle"), + t("invoices.sheet.splitFailedBody"), + ); + } finally { + setBusy(false); + } + }; + + const remove = async () => { + setBusy(true); + try { + await deleteInvoice(invoice.id); + onDeleted(invoice.id); + notify.success(t("invoices.sheet.deletedTitle"), invoice.number); + onOpenChange(false); + } catch { + notify.error( + t("invoices.sheet.deleteFailedTitle"), + t("invoices.sheet.deleteFailedBody"), + ); + } finally { + setBusy(false); + } + }; + + return ( + + + + + {invoice.number} + + + {t(`invoices.status.${invoice.status}`)} + + + + + +
+
+

+ {invoice.name} +

+

+ {t("invoices.dialog.fileNumber", { + number: invoice.fileNumber || "—", + })} +

+
+ +
+
+ {t("invoices.sheet.issued")} +
+
+ {formatInvoiceDate(invoice.issuedAt)} +
+
+ {t("invoices.sheet.due")} +
+
+ {invoice.dueAt ? formatInvoiceDate(invoice.dueAt) : "—"} +
+
+ +
+ + {t("invoices.sheet.lineItems")} + +
+ {invoice.lineItems.map((li, i) => ( +
+ + {li.description} + + + {li.quantity} × {formatMoney(li.unitPrice)} + + + {formatMoney(li.quantity * li.unitPrice)} + +
+ ))} +
+ + {t("invoices.sheet.total")} + + + {formatMoney(total)} + +
+
+
+ +
+ + {t("invoices.sheet.installments")} + + {invoice.installments.length > 0 ? ( +
+ {invoice.installments.map((it, i) => ( +
+ {it.label} + + {it.dueAt ? formatInvoiceDate(it.dueAt) : "—"} + + + {formatMoney(it.amount)} + +
+ ))} +
+ ) : ( +

+ {t("invoices.sheet.noInstallments")} +

+ )} +
+ + +
+
+ + {invoice.notes ? ( +

+ {invoice.notes} +

+ ) : null} +
+
+ + + +
+ + +
+
+
+
+ ); +} diff --git a/frontend/components/invoices/invoice-form-dialog.tsx b/frontend/components/invoices/invoice-form-dialog.tsx new file mode 100644 index 0000000..3b6087e --- /dev/null +++ b/frontend/components/invoices/invoice-form-dialog.tsx @@ -0,0 +1,451 @@ +"use client"; + +import { CalendarDays, Plus, X } from "lucide-react"; +import { + type FormEvent, + type ReactNode, + useEffect, + useMemo, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; + +import { Button } from "@/components/ui/button"; +import { Calendar } from "@/components/ui/calendar"; +import { Combobox, type ComboboxOption } from "@/components/ui/combobox"; +import { + Dialog, + DialogClose, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Popover, PopoverPopup, PopoverTrigger } from "@/components/ui/popover"; +import { + createInvoice, + formatMoney, + type Invoice, + type InvoiceLineItem, + type InvoiceStatus, + updateInvoice, +} from "@/lib/invoices"; +import { listPatients, type Patient } from "@/lib/patients"; +import { notify } from "@/lib/toast"; + +const STATUSES: InvoiceStatus[] = ["draft", "sent", "paid", "void"]; + +const controlClass = + "h-9 w-full rounded-3xl border border-transparent bg-input/50 px-3 text-sm text-foreground outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30"; + +const keyOf = (d: Date) => + `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String( + d.getDate(), + ).padStart(2, "0")}`; + +function emptyLine(): InvoiceLineItem { + return { description: "", quantity: 1, unitPrice: 0 }; +} + +function Field({ label, children }: { label: string; children: ReactNode }) { + return ( + + ); +} + +function DatePicker({ + value, + onChange, +}: { + value: Date; + onChange: (d: Date) => void; +}) { + const [open, setOpen] = useState(false); + return ( + + + + {value.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + })} + + } + /> + + { + if (d) { + onChange(d); + setOpen(false); + } + }} + selected={value} + /> + + + ); +} + +// Create or edit an invoice. The patient is chosen with a searchable combobox on +// create (locked on edit); line items are edited inline and the total updates +// live. Persists through the invoices API and hands the saved record back. +export function InvoiceFormDialog({ + open, + onOpenChange, + mode, + invoice, + onSaved, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + mode: "create" | "edit"; + invoice?: Invoice; + onSaved: (invoice: Invoice) => void; +}) { + const { t } = useTranslation(); + const [patients, setPatients] = useState([]); + const [selected, setSelected] = useState(null); + // In edit mode the patient is fixed; keep the denormalized identity. + const fixedPatient = + mode === "edit" && invoice + ? { + fileNumber: invoice.fileNumber, + name: invoice.name, + initials: invoice.initials, + } + : null; + + const [issuedAt, setIssuedAt] = useState(() => new Date()); + const [hasDue, setHasDue] = useState(false); + const [dueAt, setDueAt] = useState(() => new Date()); + const [status, setStatus] = useState("draft"); + const [notes, setNotes] = useState(""); + const [lineItems, setLineItems] = useState([emptyLine()]); + const [busy, setBusy] = useState(false); + + // Seed the form when opening. + useEffect(() => { + if (!open) return; + if (mode === "edit" && invoice) { + setIssuedAt(new Date(`${invoice.issuedAt}T00:00:00`)); + setHasDue(Boolean(invoice.dueAt)); + setDueAt(new Date(`${invoice.dueAt ?? invoice.issuedAt}T00:00:00`)); + setStatus(invoice.status); + setNotes(invoice.notes ?? ""); + setLineItems( + invoice.lineItems.length ? invoice.lineItems : [emptyLine()], + ); + } else { + setSelected(null); + setIssuedAt(new Date()); + setHasDue(false); + setDueAt(new Date()); + setStatus("draft"); + setNotes(""); + setLineItems([emptyLine()]); + } + }, [open, mode, invoice]); + + // Load patients lazily for the create combobox. + useEffect(() => { + if (!open || mode !== "create") return; + let active = true; + listPatients() + .then((data) => active && setPatients(data)) + .catch(() => { + /* search stays empty */ + }); + return () => { + active = false; + }; + }, [open, mode]); + + const patientOptions = useMemo( + () => + patients.map((p) => ({ + value: p.fileNumber, + label: `${p.name} ${p.fileNumber}`, + node: ( + + {p.name} + + #{p.fileNumber} + + + ), + })), + [patients], + ); + + const total = useMemo( + () => + lineItems.reduce((sum, li) => sum + li.quantity * li.unitPrice, 0), + [lineItems], + ); + + const updateLine = (index: number, patch: Partial) => + setLineItems((prev) => + prev.map((li, i) => (i === index ? { ...li, ...patch } : li)), + ); + + const submit = async (event: FormEvent) => { + event.preventDefault(); + const patient = fixedPatient ?? selected; + if (!patient) { + notify.error( + t("invoices.dialog.pickPatientTitle"), + t("invoices.dialog.pickPatientBody"), + ); + return; + } + const cleanLines = lineItems.filter((li) => li.description.trim()); + setBusy(true); + try { + const payload = { + fileNumber: patient.fileNumber, + name: patient.name, + initials: patient.initials, + issuedAt: keyOf(issuedAt), + dueAt: hasDue ? keyOf(dueAt) : null, + status, + lineItems: cleanLines, + notes: notes.trim() || null, + }; + const saved = + mode === "edit" && invoice + ? await updateInvoice(invoice.id, { + ...payload, + // Preserve fields the form doesn't edit. + number: invoice.number, + installments: invoice.installments, + }) + : await createInvoice(payload); + onSaved(saved); + onOpenChange(false); + } catch { + notify.error(t("invoices.addFailedTitle"), t("invoices.addFailedBody")); + } finally { + setBusy(false); + } + }; + + return ( + + + + + {mode === "edit" + ? t("invoices.dialog.editTitle") + : t("invoices.dialog.createTitle")} + + + {t("invoices.dialog.description")} + + + +
+ + + {fixedPatient ? ( +
+ + {fixedPatient.name} + + + {" "} + ·{" "} + {t("invoices.dialog.fileNumber", { + number: fixedPatient.fileNumber || "—", + })} + +
+ ) : selected ? ( +
+
+ + {selected.name} + + + {t("invoices.dialog.fileNumber", { + number: selected.fileNumber, + })} + +
+ +
+ ) : ( + { + const p = patients.find((x) => x.fileNumber === fileNumber); + if (p) setSelected(p); + }} + options={patientOptions} + placeholder={t("invoices.dialog.searchPlaceholder")} + /> + )} +
+ +
+ + + +
+ + {t("invoices.dialog.due")} + setHasDue(e.target.checked)} + type="checkbox" + /> + + {hasDue ? ( + + ) : ( +
+ — +
+ )} +
+
+ + + + + +
+
+ + {t("invoices.dialog.lineItems")} + + +
+ {lineItems.map((li, i) => ( +
+ + updateLine(i, { description: e.target.value }) + } + placeholder={t("invoices.dialog.lineDescriptionPlaceholder")} + value={li.description} + /> + + updateLine(i, { quantity: Number(e.target.value) || 0 }) + } + type="number" + value={li.quantity} + /> + + updateLine(i, { unitPrice: Number(e.target.value) || 0 }) + } + step="0.01" + type="number" + value={li.unitPrice} + /> + +
+ ))} +
+ + {t("invoices.dialog.total")} + + + {formatMoney(total)} + +
+
+ + +