Skip to main content

OdontoX Enterprise — Foundation Implementation Plan (Plan A)

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: Add the organizations, organization_clinics, organization_templates, and user_organization_assignments tables, the org_admin role, org-context middleware, and the provision-enterprise.sh script — so that a DentoCorrect enterprise instance can be provisioned and org_admin users can authenticate. Architecture: Four new schema files follow the existing server/src/schema/*.ts pattern (Drizzle, appSchema). A new requireOrgContext middleware mirrors requireClinicContext but resolves org membership instead of clinic membership. The provisioning script extends scripts/provision-instance.sh and adds seed SQL output. Tech Stack: Drizzle ORM, Hono middleware, Vitest, Bash

File Map

ActionPathResponsibility
Createserver/src/schema/organizations.tsorganizations table
Createserver/src/schema/organization_clinics.tsorg ↔ clinic mapping
Createserver/src/schema/organization_templates.tsshared templates
Createserver/src/schema/user_organization_assignments.tsorg_admin membership
Modifyserver/src/schema/index.tsexport the 4 new schemas
Createserver/drizzle/0062_organizations.sqlDB migration
Createserver/src/middleware/org-context.tsrequireOrgContext middleware
Modifyserver/src/middleware/clinic-context.tsallow org_admin to bypass clinic check
Createserver/src/lib/org-auth.tsresolveOrgContext() helper
Createserver/src/lib/org-auth.test.tsunit tests
Createscripts/provision-enterprise.shenterprise instance provisioner

Task 1: Schema — organizations table

Files:
  • Create: server/src/schema/organizations.ts
  • Step 1: Write the schema file
// server/src/schema/organizations.ts
import { pgTable, text, timestamp } from 'drizzle-orm/pg-core';
import { appSchema } from './base';

export const organizations = appSchema.table('organizations', {
  id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  slug: text('slug').unique().notNull(),
  name: text('name').notNull(),
  planTier: text('plan_tier').default('enterprise').notNull(),
  billingContactEmail: text('billing_contact_email').notNull(),
  whiteLabelDomain: text('white_label_domain'),
  customEmailSender: text('custom_email_sender'),
  createdAt: timestamp('created_at').defaultNow().notNull(),
  updatedAt: timestamp('updated_at').defaultNow().notNull(),
});

export type Organization = typeof organizations.$inferSelect;
export type NewOrganization = typeof organizations.$inferInsert;
  • Step 2: Commit
git add server/src/schema/organizations.ts
git commit -m "feat(enterprise): add organizations schema"

Task 2: Schema — organization_clinics mapping

Files:
  • Create: server/src/schema/organization_clinics.ts
  • Step 1: Write the schema file
// server/src/schema/organization_clinics.ts
import { pgTable, text, integer, timestamp, primaryKey } from 'drizzle-orm/pg-core';
import { appSchema } from './base';

export const organizationClinics = appSchema.table('organization_clinics', {
  organizationId: text('organization_id').notNull(),
  clinicId: text('clinic_id').notNull(),
  branchDisplayName: text('branch_display_name').notNull(),
  city: text('city').notNull(),
  sortOrder: integer('sort_order').default(0).notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
}, (t) => ({
  pk: primaryKey({ columns: [t.organizationId, t.clinicId] }),
}));

export type OrganizationClinic = typeof organizationClinics.$inferSelect;
export type NewOrganizationClinic = typeof organizationClinics.$inferInsert;
  • Step 2: Commit
git add server/src/schema/organization_clinics.ts
git commit -m "feat(enterprise): add organization_clinics schema"

Task 3: Schema — organization_templates

Files:
  • Create: server/src/schema/organization_templates.ts
  • Step 1: Write the schema file
// server/src/schema/organization_templates.ts
import { pgTable, text, jsonb, timestamp } from 'drizzle-orm/pg-core';
import { appSchema } from './base';

export const orgTemplateTypeEnum = [
  'treatment_plan',
  'prescription',
  'whatsapp_flow',
  'document',
  'fee_schedule',
] as const;
export type OrgTemplateType = typeof orgTemplateTypeEnum[number];

export const organizationTemplates = appSchema.table('organization_templates', {
  id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  organizationId: text('organization_id').notNull(),
  type: text('type').notNull().$type<OrgTemplateType>(),
  name: text('name').notNull(),
  contentJson: jsonb('content_json').notNull(),
  createdBy: text('created_by').notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
  updatedAt: timestamp('updated_at').defaultNow().notNull(),
});

export type OrganizationTemplate = typeof organizationTemplates.$inferSelect;
export type NewOrganizationTemplate = typeof organizationTemplates.$inferInsert;
  • Step 2: Commit
git add server/src/schema/organization_templates.ts
git commit -m "feat(enterprise): add organization_templates schema"

Task 4: Schema — user_organization_assignments

Files:
  • Create: server/src/schema/user_organization_assignments.ts
  • Step 1: Write the schema file
// server/src/schema/user_organization_assignments.ts
import { pgTable, text, timestamp, varchar } from 'drizzle-orm/pg-core';
import { appSchema } from './base';

export const userOrganizationAssignments = appSchema.table('user_organization_assignments', {
  id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  userId: text('user_id').notNull(),
  organizationId: text('organization_id').notNull(),
  role: varchar('role', { length: 50 }).default('org_admin').notNull(),
  assignedBy: text('assigned_by'),
  createdAt: timestamp('created_at').defaultNow().notNull(),
});

export type UserOrganizationAssignment = typeof userOrganizationAssignments.$inferSelect;
export type NewUserOrganizationAssignment = typeof userOrganizationAssignments.$inferInsert;
  • Step 2: Export all four schemas from index
Open server/src/schema/index.ts and add these four exports anywhere in the file:
export * from './organizations';
export * from './organization_clinics';
export * from './organization_templates';
export * from './user_organization_assignments';
  • Step 3: Commit
git add server/src/schema/user_organization_assignments.ts server/src/schema/index.ts
git commit -m "feat(enterprise): add user_organization_assignments schema + export all org schemas"

Task 5: Database migration

Files:
  • Create: server/drizzle/0062_organizations.sql
  • Step 1: Write the migration
-- server/drizzle/0062_organizations.sql
-- Enterprise: organizations, org-clinic mapping, org templates, org user assignments

CREATE TABLE IF NOT EXISTS app.organizations (
  id          text PRIMARY KEY,
  slug        text UNIQUE NOT NULL,
  name        text NOT NULL,
  plan_tier   text NOT NULL DEFAULT 'enterprise',
  billing_contact_email text NOT NULL,
  white_label_domain    text,
  custom_email_sender   text,
  created_at  timestamptz NOT NULL DEFAULT now(),
  updated_at  timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE IF NOT EXISTS app.organization_clinics (
  organization_id text NOT NULL REFERENCES app.organizations(id) ON DELETE CASCADE,
  clinic_id       text NOT NULL,
  branch_display_name text NOT NULL,
  city            text NOT NULL,
  sort_order      integer NOT NULL DEFAULT 0,
  created_at      timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (organization_id, clinic_id)
);

CREATE TABLE IF NOT EXISTS app.organization_templates (
  id              text PRIMARY KEY,
  organization_id text NOT NULL REFERENCES app.organizations(id) ON DELETE CASCADE,
  type            text NOT NULL,
  name            text NOT NULL,
  content_json    jsonb NOT NULL,
  created_by      text NOT NULL,
  created_at      timestamptz NOT NULL DEFAULT now(),
  updated_at      timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE IF NOT EXISTS app.user_organization_assignments (
  id              text PRIMARY KEY,
  user_id         text NOT NULL,
  organization_id text NOT NULL REFERENCES app.organizations(id) ON DELETE CASCADE,
  role            varchar(50) NOT NULL DEFAULT 'org_admin',
  assigned_by     text,
  created_at      timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_org_clinics_clinic_id
  ON app.organization_clinics(clinic_id);

CREATE INDEX IF NOT EXISTS idx_org_templates_org_id
  ON app.organization_templates(organization_id);

CREATE INDEX IF NOT EXISTS idx_user_org_assignments_user_id
  ON app.user_organization_assignments(user_id);
  • Step 2: Commit
git add server/drizzle/0062_organizations.sql
git commit -m "feat(enterprise): migration 0062 — org tables"

Task 6: Org auth helper + tests

Files:
  • Create: server/src/lib/org-auth.ts
  • Create: server/src/lib/org-auth.test.ts
  • Step 1: Write failing tests first
// server/src/lib/org-auth.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { resolveOrgContext } from './org-auth';

const mockDb = {
  select: vi.fn(),
};

vi.mock('./db', () => ({
  getReadDb: () => mockDb,
}));

describe('resolveOrgContext', () => {
  beforeEach(() => { vi.clearAllMocks(); });

  it('returns null when user has no org assignment', async () => {
    mockDb.select.mockReturnValue({
      from: () => ({ innerJoin: () => ({ where: () => Promise.resolve([]) }) }),
    });
    const result = await resolveOrgContext('user-123');
    expect(result).toBeNull();
  });

  it('returns org context when valid assignment exists', async () => {
    mockDb.select.mockReturnValue({
      from: () => ({
        innerJoin: () => ({
          where: () => Promise.resolve([{
            organizationId: 'org-abc',
            organizationSlug: 'dentocorrect',
            organizationName: 'DentoCorrect',
            role: 'org_admin',
          }]),
        }),
      }),
    });
    const result = await resolveOrgContext('user-123');
    expect(result).toEqual({
      organizationId: 'org-abc',
      organizationSlug: 'dentocorrect',
      organizationName: 'DentoCorrect',
      role: 'org_admin',
    });
  });
});
  • Step 2: Run tests — confirm they fail
cd server && npx vitest run src/lib/org-auth.test.ts
Expected: FAIL — resolveOrgContext not found
  • Step 3: Implement resolveOrgContext
// server/src/lib/org-auth.ts
import { eq } from 'drizzle-orm';
import { getReadDb } from './db';
import { userOrganizationAssignments, organizations } from '../schema';

export interface OrgContext {
  organizationId: string;
  organizationSlug: string;
  organizationName: string;
  role: string;
}

export async function resolveOrgContext(userId: string): Promise<OrgContext | null> {
  const db = getReadDb();
  const rows = await db
    .select({
      organizationId: userOrganizationAssignments.organizationId,
      organizationSlug: organizations.slug,
      organizationName: organizations.name,
      role: userOrganizationAssignments.role,
    })
    .from(userOrganizationAssignments)
    .innerJoin(organizations, eq(organizations.id, userOrganizationAssignments.organizationId))
    .where(eq(userOrganizationAssignments.userId, userId));

  if (!rows.length) return null;
  return rows[0];
}
  • Step 4: Run tests — confirm they pass
cd server && npx vitest run src/lib/org-auth.test.ts
Expected: PASS (2 tests)
  • Step 5: Commit
git add server/src/lib/org-auth.ts server/src/lib/org-auth.test.ts
git commit -m "feat(enterprise): resolveOrgContext helper + tests"

Task 7: Org-context middleware

Files:
  • Create: server/src/middleware/org-context.ts
  • Modify: server/src/middleware/clinic-context.ts
  • Step 1: Write the org-context middleware
// server/src/middleware/org-context.ts
import { MiddlewareHandler } from 'hono';
import { resolveOrgContext, OrgContext } from '../lib/org-auth';
import { AppError } from '../lib/errors';

declare module 'hono' {
  interface ContextVariableMap {
    orgContext: OrgContext;
  }
}

/**
 * Resolves and validates org membership for org_admin users.
 * Must be applied after the standard auth middleware (which sets c.get('user')).
 * Throws 403 if the authenticated user has no org assignment.
 */
export const requireOrgContext: MiddlewareHandler = async (c, next) => {
  const user = c.get('user');
  if (!user) throw new AppError('Unauthenticated', 401);

  const orgContext = await resolveOrgContext(user.id);
  if (!orgContext) throw new AppError('No organization access.', 403);

  c.set('orgContext', orgContext);
  await next();
};
  • Step 2: Allow org_admin to bypass requireClinicContext
In server/src/middleware/clinic-context.ts, find the requireClinicContext function and add an org_admin bypass immediately after the superadmin check:
// Add after the superadmin block, before the main clinic check:
if (user.role === 'org_admin') {
  // org_admin is resolved via requireOrgContext on /org/* routes.
  // They are allowed through clinic-context without a currentClinicId
  // so they can drill into any branch within their org.
  await next();
  return;
}
  • Step 3: Commit
git add server/src/middleware/org-context.ts server/src/middleware/clinic-context.ts
git commit -m "feat(enterprise): requireOrgContext middleware + org_admin bypass in clinic-context"

Task 8: Provisioning script

Files:
  • Create: scripts/provision-enterprise.sh
  • Step 1: Write the script
#!/usr/bin/env bash
# scripts/provision-enterprise.sh
# Usage: scripts/provision-enterprise.sh <slug> <org-name> <subdomain>
# Example: scripts/provision-enterprise.sh dentocorrect "DentoCorrect" dc.odontox.io

set -euo pipefail

if [ "${1:-}" = "" ] || [ "${2:-}" = "" ] || [ "${3:-}" = "" ]; then
  echo "Usage: scripts/provision-enterprise.sh <slug> <org-name> <subdomain>"
  echo "Example: scripts/provision-enterprise.sh dentocorrect \"DentoCorrect\" dc.odontox.io"
  exit 1
fi

SLUG="$1"
ORG_NAME="$2"
SUBDOMAIN="$3"
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
INSTANCE_DIR="$ROOT_DIR/deploy/instances/$SLUG"
WORKER_NAME="odonto-${SLUG}"
DB_NAME="odontox_${SLUG}"
ORG_ID="$(uuidgen | tr '[:upper:]' '[:lower:]')"
FIRST_ADMIN_ID="$(uuidgen | tr '[:upper:]' '[:lower:]')"

mkdir -p "$INSTANCE_DIR"

# ── instance.json ──────────────────────────────────────────────────────────────
cat > "$INSTANCE_DIR/instance.json" <<EOF
{
  "slug": "$SLUG",
  "orgName": "$ORG_NAME",
  "subdomain": "$SUBDOMAIN",
  "workerName": "$WORKER_NAME",
  "databaseName": "$DB_NAME",
  "orgId": "$ORG_ID",
  "createdAt": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
}
EOF

# ── .env.template ──────────────────────────────────────────────────────────────
cat > "$INSTANCE_DIR/.env.template" <<EOF
APP_URL=https://$SUBDOMAIN
API_URL=https://api.$SUBDOMAIN
PORTAL_URL=https://portal.$SUBDOMAIN
DATABASE_URL=postgres://USER:PASSWORD@HOST/$DB_NAME
OPENAI_API_KEY=
ZEPTO_API_KEY=
[email protected]
EOF

# ── wrangler.instance.toml ─────────────────────────────────────────────────────
cat > "$INSTANCE_DIR/wrangler.instance.toml" <<EOF
name = "$WORKER_NAME"
main = "src/worker.ts"
compatibility_date = "2024-09-23"
compatibility_flags = ["nodejs_compat"]

[vars]
APP_URL = "https://$SUBDOMAIN"
PORTAL_URL = "https://portal.$SUBDOMAIN"
RUNTIME = "cloudflare"
INSTANCE_SLUG = "$SLUG"
EOF

# ── seed.sql ───────────────────────────────────────────────────────────────────
cat > "$INSTANCE_DIR/seed.sql" <<EOF
-- Run against the isolated DB after migrations.
-- Provisions the organization and a placeholder org_admin user.
-- Replace PASSWORD_HASH with a real bcrypt hash before running.

INSERT INTO app.organizations (id, slug, name, plan_tier, billing_contact_email)
VALUES ('$ORG_ID', '$SLUG', '$ORG_NAME', 'enterprise', 'billing@$SLUG.local')
ON CONFLICT (slug) DO NOTHING;

-- Replace email and password_hash before running.
INSERT INTO app.users (id, email, password_hash, first_name, last_name, role, status, is_active, is_onboarded, account_type)
VALUES ('$FIRST_ADMIN_ID', 'admin@$SLUG.local', 'REPLACE_WITH_BCRYPT_HASH', 'Head', 'Office', 'org_admin', 'active', true, true, 'login')
ON CONFLICT DO NOTHING;

INSERT INTO app.user_organization_assignments (id, user_id, organization_id, role)
VALUES ('$(uuidgen | tr '[:upper:]' '[:lower:]')', '$FIRST_ADMIN_ID', '$ORG_ID', 'org_admin')
ON CONFLICT DO NOTHING;
EOF

# ── README.md ──────────────────────────────────────────────────────────────────
cat > "$INSTANCE_DIR/README.md" <<EOF
# $ORG_NAME Enterprise Instance

Generated by \`scripts/provision-enterprise.sh\` on $(date -u +"%Y-%m-%d").

## Checklist

- [ ] Create Neon database: \`$DB_NAME\`
- [ ] Fill \`.env.template\` values, add secrets to CF Worker \`$WORKER_NAME\`
- [ ] Run migrations: \`DATABASE_URL=<url> npx drizzle-kit push\`
- [ ] Create CF Pages project: \`odontox-$SLUG\`
- [ ] Deploy Worker: \`wrangler deploy --config deploy/instances/$SLUG/wrangler.instance.toml\`
- [ ] Add CF Worker route for \`$SUBDOMAIN/*\`\`$WORKER_NAME\`
- [ ] Edit \`seed.sql\`: set real email + bcrypt password hash for org_admin
- [ ] Run \`seed.sql\` against the isolated DB
- [ ] Verify login at https://$SUBDOMAIN
- [ ] (Optional) Add-on: CNAME \`app.dentocorrect.pk\`\`$SUBDOMAIN\` then add CF custom domain
EOF

echo ""
echo "✓ Enterprise instance scaffold created at: $INSTANCE_DIR"
echo ""
echo "Next: fill .env.template, then follow the checklist in $INSTANCE_DIR/README.md"
  • Step 2: Make executable
chmod +x scripts/provision-enterprise.sh
  • Step 3: Smoke test
scripts/provision-enterprise.sh test-corp "Test Corp" test.odontox.io
ls deploy/instances/test-corp/
cat deploy/instances/test-corp/instance.json
rm -rf deploy/instances/test-corp/
Expected output: instance.json, .env.template, wrangler.instance.toml, seed.sql, README.md
  • Step 4: Commit
git add scripts/provision-enterprise.sh
git commit -m "feat(enterprise): provision-enterprise.sh — scaffold + seed SQL for new org instances"

Task 9: Register /api/v1/org routes skeleton

This task wires the route prefix so Plan B (Network Hub) can mount its handlers. Files:
  • Create: server/src/routes/org.ts
  • Modify: server/src/api.ts
  • Step 1: Write the skeleton route file
// server/src/routes/org.ts
import { Hono } from 'hono';
import { requireOrgContext } from '../middleware/org-context';

const org = new Hono();
org.use('*', requireOrgContext);

// Placeholder — Plan B mounts real handlers here.
org.get('/ping', (c) => {
  const orgCtx = c.get('orgContext');
  return c.json({ ok: true, org: orgCtx.organizationSlug });
});

export default org;
  • Step 2: Mount the route in api.ts
In server/src/api.ts, find where protectedRoutes registers other routes (search for protectedRoutes.route() and add:
import orgRoutes from './routes/org';
// ...
protectedRoutes.route('/org', orgRoutes);
  • Step 3: Commit
git add server/src/routes/org.ts server/src/api.ts
git commit -m "feat(enterprise): mount /api/v1/org route prefix with requireOrgContext"

Task 10: Apply migration to DentoCorrect instance

This task is operator-run, not code. Document it here for the executor.
  • Connect to the odontox_dentocorrect Neon database
  • Run: psql $DATABASE_URL -f server/drizzle/0062_organizations.sql
  • Verify: \dt app.organ* should show 4 new tables
  • Edit deploy/instances/dentocorrect/seed.sql: set real billing email + bcrypt hash
  • Run seed: psql $DATABASE_URL -f deploy/instances/dentocorrect/seed.sql
  • Verify org_admin login at dc.odontox.io

Self-Review Checklist

Spec coverage:
  • organizations table — Task 1
  • organization_clinics — Task 2
  • organization_templates — Task 3
  • user_organization_assignments — Task 4
  • DB migration — Task 5
  • org_admin role + auth resolution — Tasks 6–7
  • provision-enterprise.sh — Task 8
  • Route prefix — Task 9
  • Network Hub UI + org API routes — Plan B (separate plan)
Plan B prerequisite: All tasks in Plan A must be deployed before starting Plan B.