feat: widgets and ai chat

This commit is contained in:
Rotimi Best
2026-04-27 11:40:38 +01:00
parent ae1801f85f
commit 5717dd8004
391 changed files with 28177 additions and 22467 deletions
+30 -1
View File
@@ -2,6 +2,7 @@ import * as CONSTANTS from './constants';
import * as schema from '@db/schema';
import { admin, anonymous } from 'better-auth/plugins';
import { customSession } from 'better-auth/plugins/custom-session';
import { sendChangeEmailConfirmation, sendVerificationEmail } from './auth/email-verification';
import { betterAuth } from 'better-auth/minimal';
@@ -13,6 +14,8 @@ import { loginLink } from './auth/plugins/login-link';
import { sso } from '@better-auth/sso';
import { syncUserWithProfile } from './auth/hooks/sync-user';
import { tokenExchange } from './auth/plugins/token-exchange';
import { trackLoginHook } from './auth/hooks/track-login';
import { getUserOrgRolesMap } from './queries/organization/organization';
export const auth: ReturnType<typeof betterAuth> = betterAuth({
baseURL: CONSTANTS.BASE_URL,
@@ -76,6 +79,18 @@ export const auth: ReturnType<typeof betterAuth> = betterAuth({
await syncUserWithProfile(user);
}
}
},
session: {
create: {
after: async (session) => {
await trackLoginHook(session);
}
},
update: {
after: async (session) => {
await trackLoginHook(session);
}
}
}
},
plugins: [
@@ -86,6 +101,20 @@ export const auth: ReturnType<typeof betterAuth> = betterAuth({
// via the admin API (auth.api.registerSSOProvider)
}),
loginLink(),
tokenExchange()
tokenExchange(),
// Attaches the user's org memberships ({ [orgId]: roleId }) to the session
// so org-scoped middlewares can authorize without a per-request DB query.
// Refreshes when the session cookie cache expires (see session.cookieCache.maxAge).
customSession(async ({ user, session }) => {
let orgRoles: Record<string, number> = {};
try {
if (user?.id) {
orgRoles = await getUserOrgRolesMap(user.id);
}
} catch (error) {
console.error('customSession: failed to load orgRoles', error);
}
return { user, session, orgRoles };
})
]
});
+26
View File
@@ -0,0 +1,26 @@
import * as schema from '@db/schema';
import { Session } from 'better-auth';
import { db } from '@db/drizzle';
/**
* Records one login per user per day in analytics_login_events.
*
* Why: powers the org admin "most active login days" chart. The unique
* constraint on (user_id, logged_in_date) plus onConflictDoNothing keeps
* the row count at most one per user per calendar day.
*/
export const trackLoginHook = async (session: Pick<Session, 'userId'>) => {
if (!session?.userId) return;
try {
await db
.insert(schema.analyticsLoginEvents)
.values({ userId: session.userId })
.onConflictDoNothing({
target: [schema.analyticsLoginEvents.userId, schema.analyticsLoginEvents.loggedInDate]
});
} catch (error) {
console.error('trackLoginHook error:', error);
}
};
@@ -0,0 +1,21 @@
CREATE TABLE IF NOT EXISTS "ai_chat_document" (
"id" text PRIMARY KEY NOT NULL,
"conversation_id" uuid NOT NULL,
"course_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
"asset_id" uuid,
"file_name" text NOT NULL,
"mime_type" text NOT NULL,
"text" text NOT NULL,
"word_count" integer DEFAULT 0 NOT NULL,
"page_count" integer,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "ai_chat_document" ADD CONSTRAINT "ai_chat_document_conversation_id_fkey" FOREIGN KEY ("conversation_id") REFERENCES "ai_chat_conversation"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "idx_ai_chat_document_conversation" ON "ai_chat_document" ("conversation_id");
@@ -1,2 +0,0 @@
CREATE INDEX "idx_course_group_id" ON "course" USING btree ("group_id");--> statement-breakpoint
CREATE INDEX "idx_group_organization_id" ON "group" USING btree ("organization_id");
@@ -1,101 +0,0 @@
CREATE TYPE "public"."COURSE_INVITE_EVENT_TYPE" AS ENUM('CREATED', 'REVOKED', 'PREVIEWED', 'ACCEPTED', 'EMAIL_SENT', 'EMAIL_FAILED', 'ABUSE_BLOCKED');--> statement-breakpoint
CREATE TYPE "public"."ORGANIZATION_INVITE_EVENT_TYPE" AS ENUM('CREATED', 'REVOKED', 'PREVIEWED', 'ACCEPTED', 'EMAIL_SENT', 'EMAIL_FAILED', 'ABUSE_BLOCKED');--> statement-breakpoint
CREATE TABLE "course_invite" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"course_id" uuid NOT NULL,
"role_id" bigint DEFAULT '3' NOT NULL,
"token_hash" text NOT NULL,
"created_by_profile_id" uuid NOT NULL,
"revoked_by_profile_id" uuid,
"revoked_at" timestamp with time zone,
"expires_at" timestamp with time zone NOT NULL,
"max_uses" integer DEFAULT 1 NOT NULL,
"used_count" integer DEFAULT 0 NOT NULL,
"is_revoked" boolean DEFAULT false NOT NULL,
"allowed_emails" text[],
"allowed_domains" text[],
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
"updated_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
"last_used_at" timestamp with time zone,
CONSTRAINT "course_invite_token_hash_key" UNIQUE("token_hash")
);
--> statement-breakpoint
CREATE TABLE "course_invite_audit" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"invite_id" uuid NOT NULL,
"course_id" uuid NOT NULL,
"event_type" "COURSE_INVITE_EVENT_TYPE" NOT NULL,
"actor_profile_id" uuid,
"target_email" varchar,
"ip_address" text,
"user_agent" text,
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL
);
--> statement-breakpoint
CREATE TABLE "organization_invite" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"role_id" bigint NOT NULL,
"email" text NOT NULL,
"token_hash" text NOT NULL,
"created_by_profile_id" uuid NOT NULL,
"accepted_by_profile_id" uuid,
"accepted_at" timestamp with time zone,
"revoked_by_profile_id" uuid,
"revoked_at" timestamp with time zone,
"expires_at" timestamp with time zone NOT NULL,
"is_revoked" boolean DEFAULT false NOT NULL,
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
"updated_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
CONSTRAINT "organization_invite_token_hash_key" UNIQUE("token_hash")
);
--> statement-breakpoint
CREATE TABLE "organization_invite_audit" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"invite_id" uuid NOT NULL,
"organization_id" uuid NOT NULL,
"event_type" "ORGANIZATION_INVITE_EVENT_TYPE" NOT NULL,
"actor_profile_id" uuid,
"target_email" varchar,
"ip_address" text,
"user_agent" text,
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL
);
--> statement-breakpoint
ALTER TABLE "course_invite" ADD CONSTRAINT "course_invite_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "public"."course"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "course_invite" ADD CONSTRAINT "course_invite_created_by_profile_id_fkey" FOREIGN KEY ("created_by_profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "course_invite" ADD CONSTRAINT "course_invite_revoked_by_profile_id_fkey" FOREIGN KEY ("revoked_by_profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "course_invite" ADD CONSTRAINT "course_invite_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "public"."role"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "course_invite_audit" ADD CONSTRAINT "course_invite_audit_invite_id_fkey" FOREIGN KEY ("invite_id") REFERENCES "public"."course_invite"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "course_invite_audit" ADD CONSTRAINT "course_invite_audit_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "public"."course"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "course_invite_audit" ADD CONSTRAINT "course_invite_audit_actor_profile_id_fkey" FOREIGN KEY ("actor_profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization_invite" ADD CONSTRAINT "organization_invite_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization_invite" ADD CONSTRAINT "organization_invite_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "public"."role"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization_invite" ADD CONSTRAINT "organization_invite_created_by_profile_id_fkey" FOREIGN KEY ("created_by_profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization_invite" ADD CONSTRAINT "organization_invite_accepted_by_profile_id_fkey" FOREIGN KEY ("accepted_by_profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization_invite" ADD CONSTRAINT "organization_invite_revoked_by_profile_id_fkey" FOREIGN KEY ("revoked_by_profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization_invite_audit" ADD CONSTRAINT "organization_invite_audit_invite_id_fkey" FOREIGN KEY ("invite_id") REFERENCES "public"."organization_invite"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization_invite_audit" ADD CONSTRAINT "organization_invite_audit_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization_invite_audit" ADD CONSTRAINT "organization_invite_audit_actor_profile_id_fkey" FOREIGN KEY ("actor_profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_course_invite_course_id" ON "course_invite" USING btree ("course_id");--> statement-breakpoint
CREATE INDEX "idx_course_invite_expires_at" ON "course_invite" USING btree ("expires_at");--> statement-breakpoint
CREATE INDEX "idx_course_invite_created_by" ON "course_invite" USING btree ("created_by_profile_id");--> statement-breakpoint
CREATE INDEX "idx_course_invite_revoked_by" ON "course_invite" USING btree ("revoked_by_profile_id");--> statement-breakpoint
CREATE INDEX "idx_course_invite_audit_invite_id" ON "course_invite_audit" USING btree ("invite_id");--> statement-breakpoint
CREATE INDEX "idx_course_invite_audit_course_id" ON "course_invite_audit" USING btree ("course_id");--> statement-breakpoint
CREATE INDEX "idx_course_invite_audit_event_type" ON "course_invite_audit" USING btree ("event_type");--> statement-breakpoint
CREATE INDEX "idx_course_invite_audit_created_at" ON "course_invite_audit" USING btree ("created_at");--> statement-breakpoint
CREATE INDEX "idx_organization_invite_organization_id" ON "organization_invite" USING btree ("organization_id");--> statement-breakpoint
CREATE INDEX "idx_organization_invite_email_org" ON "organization_invite" USING btree ("email","organization_id");--> statement-breakpoint
CREATE INDEX "idx_organization_invite_expires_at" ON "organization_invite" USING btree ("expires_at");--> statement-breakpoint
CREATE INDEX "idx_organization_invite_created_by" ON "organization_invite" USING btree ("created_by_profile_id");--> statement-breakpoint
CREATE INDEX "idx_organization_invite_accepted_by" ON "organization_invite" USING btree ("accepted_by_profile_id");--> statement-breakpoint
CREATE INDEX "idx_organization_invite_revoked_by" ON "organization_invite" USING btree ("revoked_by_profile_id");--> statement-breakpoint
CREATE INDEX "idx_organization_invite_audit_invite_id" ON "organization_invite_audit" USING btree ("invite_id");--> statement-breakpoint
CREATE INDEX "idx_organization_invite_audit_org_id" ON "organization_invite_audit" USING btree ("organization_id");--> statement-breakpoint
CREATE INDEX "idx_organization_invite_audit_event_type" ON "organization_invite_audit" USING btree ("event_type");--> statement-breakpoint
CREATE INDEX "idx_organization_invite_audit_created_at" ON "organization_invite_audit" USING btree ("created_at");
@@ -1,52 +0,0 @@
CREATE TABLE "assets" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"kind" varchar DEFAULT 'video' NOT NULL,
"provider" varchar DEFAULT 'upload' NOT NULL,
"storage_provider" varchar DEFAULT 's3' NOT NULL,
"storage_key" text,
"source_url" text,
"mime_type" text,
"byte_size" bigint,
"checksum" text,
"title" text,
"description" text,
"thumbnail_url" text,
"duration_seconds" integer,
"aspect_ratio" text,
"is_external" boolean DEFAULT false NOT NULL,
"status" varchar DEFAULT 'active' NOT NULL,
"metadata" jsonb DEFAULT '{}'::jsonb,
"created_by_profile_id" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now(),
CONSTRAINT "assets_org_provider_storage_key_unique" UNIQUE("organization_id","provider","storage_key")
);
--> statement-breakpoint
CREATE TABLE "asset_usages" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"asset_id" uuid NOT NULL,
"target_type" varchar NOT NULL,
"target_id" text NOT NULL,
"slot_type" varchar DEFAULT 'lesson_video' NOT NULL,
"slot_key" text,
"position" integer,
"created_by_profile_id" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "asset_usages_asset_target_slot_unique" UNIQUE("asset_id","target_type","target_id","slot_type","slot_key","position")
);
--> statement-breakpoint
ALTER TABLE "assets" ADD CONSTRAINT "assets_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "assets" ADD CONSTRAINT "assets_created_by_profile_id_fkey" FOREIGN KEY ("created_by_profile_id") REFERENCES "public"."profile"("id") ON DELETE set null ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "asset_usages" ADD CONSTRAINT "asset_usages_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "asset_usages" ADD CONSTRAINT "asset_usages_asset_id_fkey" FOREIGN KEY ("asset_id") REFERENCES "public"."assets"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "asset_usages" ADD CONSTRAINT "asset_usages_created_by_profile_id_fkey" FOREIGN KEY ("created_by_profile_id") REFERENCES "public"."profile"("id") ON DELETE set null ON UPDATE cascade;--> statement-breakpoint
CREATE INDEX "idx_assets_organization_id" ON "assets" USING btree ("organization_id");--> statement-breakpoint
CREATE INDEX "idx_assets_organization_status" ON "assets" USING btree ("organization_id","status");--> statement-breakpoint
CREATE INDEX "idx_assets_organization_kind_status" ON "assets" USING btree ("organization_id","kind","status");--> statement-breakpoint
CREATE INDEX "idx_assets_organization_created_at" ON "assets" USING btree ("organization_id","created_at");--> statement-breakpoint
CREATE INDEX "idx_assets_organization_byte_size" ON "assets" USING btree ("organization_id","byte_size");--> statement-breakpoint
CREATE INDEX "idx_asset_usages_org_id" ON "asset_usages" USING btree ("organization_id");--> statement-breakpoint
CREATE INDEX "idx_asset_usages_asset_id" ON "asset_usages" USING btree ("asset_id");--> statement-breakpoint
CREATE INDEX "idx_asset_usages_target" ON "asset_usages" USING btree ("target_type","target_id");
@@ -1,184 +0,0 @@
CREATE TYPE "public"."AUTOMATION_USAGE_CATEGORY" AS ENUM('read', 'write', 'publish');--> statement-breakpoint
CREATE TYPE "public"."COURSE_IMPORT_DRAFT_STATUS" AS ENUM('DRAFT', 'PUBLISHED', 'ARCHIVED');--> statement-breakpoint
CREATE TYPE "public"."COURSE_IMPORT_SOURCE_TYPE" AS ENUM('prompt', 'pdf', 'course');--> statement-breakpoint
CREATE TYPE "public"."ORGANIZATION_API_KEY_TYPE" AS ENUM('mcp', 'api', 'zapier');--> statement-breakpoint
CREATE TYPE "public"."SSO_PROVIDER" AS ENUM('OKTA', 'GOOGLE_WORKSPACE', 'AUTH0');--> statement-breakpoint
CREATE TABLE "course_import_draft" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"created_by_profile_id" uuid NOT NULL,
"source_type" "COURSE_IMPORT_SOURCE_TYPE" NOT NULL,
"status" "COURSE_IMPORT_DRAFT_STATUS" DEFAULT 'DRAFT' NOT NULL,
"title" varchar NOT NULL,
"locale" "LOCALE" DEFAULT 'en' NOT NULL,
"idempotency_key" varchar,
"summary" jsonb DEFAULT '{}'::jsonb NOT NULL,
"draft" jsonb NOT NULL,
"warnings" jsonb DEFAULT '[]'::jsonb NOT NULL,
"sourceArtifacts" jsonb DEFAULT '[]'::jsonb NOT NULL,
"published_course_id" uuid,
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
"updated_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL
);
--> statement-breakpoint
CREATE TABLE "organization_api_key" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"created_by_profile_id" uuid NOT NULL,
"type" "ORGANIZATION_API_KEY_TYPE" NOT NULL,
"label" varchar(120) NOT NULL,
"secret_prefix" varchar(32) NOT NULL,
"secret_hash" text NOT NULL,
"scopes" jsonb DEFAULT '[]'::jsonb NOT NULL,
"last_used_at" timestamp with time zone,
"expires_at" timestamp with time zone,
"revoked_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
"updated_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL
);
--> statement-breakpoint
CREATE TABLE "organization_auth_policy" (
"organization_id" uuid PRIMARY KEY NOT NULL,
"force_sso" boolean DEFAULT false NOT NULL,
"auto_join_sso_domains" boolean DEFAULT false NOT NULL,
"break_glass_enabled" boolean DEFAULT true NOT NULL,
"default_role_id" bigint DEFAULT '3' NOT NULL,
"role_mapping" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
"updated_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL
);
--> statement-breakpoint
CREATE TABLE "organization_automation_usage" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"organization_api_key_id" uuid,
"type" "ORGANIZATION_API_KEY_TYPE" NOT NULL,
"action" varchar(120) NOT NULL,
"category" "AUTOMATION_USAGE_CATEGORY" NOT NULL,
"credits_consumed" integer DEFAULT 0 NOT NULL,
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL
);
--> statement-breakpoint
CREATE TABLE "organization_sso_config" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"better_auth_provider_id" text NOT NULL,
"provider" "SSO_PROVIDER" NOT NULL,
"display_name" text NOT NULL,
"domain" text NOT NULL,
"is_active" boolean DEFAULT false NOT NULL,
"created_by_profile_id" uuid NOT NULL,
"updated_by_profile_id" uuid,
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
"updated_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
CONSTRAINT "organization_sso_config_organization_id_unique" UNIQUE("organization_id")
);
--> statement-breakpoint
CREATE TABLE "organization_token_auth" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"signing_secret" text NOT NULL,
"is_active" boolean DEFAULT false NOT NULL,
"created_by_profile_id" uuid NOT NULL,
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
"updated_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
CONSTRAINT "organization_token_auth_organization_id_unique" UNIQUE("organization_id")
);
--> statement-breakpoint
CREATE TABLE "sso_provider" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"issuer" text NOT NULL,
"oidc_config" text,
"saml_config" text,
"user_id" uuid NOT NULL,
"provider_id" text NOT NULL,
"organization_id" text,
"domain" text NOT NULL,
CONSTRAINT "sso_provider_provider_id_unique" UNIQUE("provider_id")
);
--> statement-breakpoint
CREATE TABLE "tag" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"group_id" uuid NOT NULL,
"name" varchar NOT NULL,
"slug" text NOT NULL,
"description" text,
"color" varchar NOT NULL,
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
"updated_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
CONSTRAINT "tag_org_slug_key" UNIQUE("organization_id","slug")
);
--> statement-breakpoint
CREATE TABLE "tag_assignment" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tag_id" uuid NOT NULL,
"course_id" uuid NOT NULL,
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
CONSTRAINT "tag_assignment_tag_course_key" UNIQUE("tag_id","course_id")
);
--> statement-breakpoint
CREATE TABLE "tag_group" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"name" varchar NOT NULL,
"slug" text NOT NULL,
"description" text,
"order" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
"updated_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
CONSTRAINT "tag_group_org_slug_key" UNIQUE("organization_id","slug")
);
--> statement-breakpoint
ALTER TABLE "organization" ALTER COLUMN "theme" SET DEFAULT 'blue';--> statement-breakpoint
ALTER TABLE "option" ADD COLUMN "settings" jsonb DEFAULT '{}'::jsonb;--> statement-breakpoint
ALTER TABLE "organization" ADD COLUMN "disable_signup" boolean DEFAULT false;--> statement-breakpoint
ALTER TABLE "organization" ADD COLUMN "disable_signup_message" text;--> statement-breakpoint
ALTER TABLE "organization" ADD COLUMN "disable_email_password" boolean DEFAULT false;--> statement-breakpoint
ALTER TABLE "organization" ADD COLUMN "disable_google_auth" boolean DEFAULT false;--> statement-breakpoint
ALTER TABLE "question" ADD COLUMN "settings" jsonb DEFAULT '{}'::jsonb;--> statement-breakpoint
ALTER TABLE "question_answer" ADD COLUMN "answer_data" jsonb;--> statement-breakpoint
ALTER TABLE "submission" ADD COLUMN "grading_state" varchar DEFAULT 'queued' NOT NULL;--> statement-breakpoint
ALTER TABLE "submission" ADD COLUMN "overall_status" varchar DEFAULT 'manual_required' NOT NULL;--> statement-breakpoint
ALTER TABLE "course_import_draft" ADD CONSTRAINT "course_import_draft_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "course_import_draft" ADD CONSTRAINT "course_import_draft_created_by_profile_id_profile_id_fk" FOREIGN KEY ("created_by_profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "course_import_draft" ADD CONSTRAINT "course_import_draft_published_course_id_course_id_fk" FOREIGN KEY ("published_course_id") REFERENCES "public"."course"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization_api_key" ADD CONSTRAINT "organization_api_key_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization_api_key" ADD CONSTRAINT "organization_api_key_created_by_profile_id_profile_id_fk" FOREIGN KEY ("created_by_profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization_auth_policy" ADD CONSTRAINT "organization_auth_policy_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization_auth_policy" ADD CONSTRAINT "organization_auth_policy_default_role_id_fkey" FOREIGN KEY ("default_role_id") REFERENCES "public"."role"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization_automation_usage" ADD CONSTRAINT "organization_automation_usage_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization_automation_usage" ADD CONSTRAINT "organization_automation_usage_organization_api_key_id_organization_api_key_id_fk" FOREIGN KEY ("organization_api_key_id") REFERENCES "public"."organization_api_key"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization_sso_config" ADD CONSTRAINT "organization_sso_config_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization_sso_config" ADD CONSTRAINT "organization_sso_config_created_by_profile_id_profile_id_fk" FOREIGN KEY ("created_by_profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization_sso_config" ADD CONSTRAINT "organization_sso_config_updated_by_profile_id_profile_id_fk" FOREIGN KEY ("updated_by_profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization_token_auth" ADD CONSTRAINT "organization_token_auth_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization_token_auth" ADD CONSTRAINT "organization_token_auth_created_by_profile_id_profile_id_fk" FOREIGN KEY ("created_by_profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sso_provider" ADD CONSTRAINT "sso_provider_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "tag" ADD CONSTRAINT "tag_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "tag" ADD CONSTRAINT "tag_group_id_fkey" FOREIGN KEY ("group_id") REFERENCES "public"."tag_group"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "tag_assignment" ADD CONSTRAINT "tag_assignment_tag_id_fkey" FOREIGN KEY ("tag_id") REFERENCES "public"."tag"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "tag_assignment" ADD CONSTRAINT "tag_assignment_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "public"."course"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "tag_group" ADD CONSTRAINT "tag_group_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_course_import_draft_org_id" ON "course_import_draft" USING btree ("organization_id");--> statement-breakpoint
CREATE INDEX "idx_course_import_draft_status" ON "course_import_draft" USING btree ("status");--> statement-breakpoint
CREATE UNIQUE INDEX "course_import_draft_org_idempotency_key" ON "course_import_draft" USING btree ("organization_id","idempotency_key") WHERE "course_import_draft"."idempotency_key" IS NOT NULL;--> statement-breakpoint
CREATE INDEX "idx_organization_api_key_org_id" ON "organization_api_key" USING btree ("organization_id");--> statement-breakpoint
CREATE INDEX "idx_organization_api_key_type" ON "organization_api_key" USING btree ("type");--> statement-breakpoint
CREATE UNIQUE INDEX "organization_api_key_secret_hash_unique" ON "organization_api_key" USING btree ("secret_hash");--> statement-breakpoint
CREATE INDEX "idx_organization_auth_policy_org_id" ON "organization_auth_policy" USING btree ("organization_id");--> statement-breakpoint
CREATE INDEX "idx_org_automation_usage_org_created_at" ON "organization_automation_usage" USING btree ("organization_id","created_at");--> statement-breakpoint
CREATE INDEX "idx_org_automation_usage_key_created_at" ON "organization_automation_usage" USING btree ("organization_api_key_id","created_at");--> statement-breakpoint
CREATE INDEX "idx_org_automation_usage_type_created_at" ON "organization_automation_usage" USING btree ("type","created_at");--> statement-breakpoint
CREATE INDEX "idx_organization_sso_config_org_id" ON "organization_sso_config" USING btree ("organization_id");--> statement-breakpoint
CREATE INDEX "idx_organization_sso_config_domain" ON "organization_sso_config" USING btree ("domain");--> statement-breakpoint
CREATE INDEX "idx_organization_sso_config_provider_id" ON "organization_sso_config" USING btree ("better_auth_provider_id");--> statement-breakpoint
CREATE INDEX "idx_organization_token_auth_org_id" ON "organization_token_auth" USING btree ("organization_id");--> statement-breakpoint
CREATE INDEX "idx_tag_organization_id" ON "tag" USING btree ("organization_id");--> statement-breakpoint
CREATE INDEX "idx_tag_group_id" ON "tag" USING btree ("group_id");--> statement-breakpoint
CREATE INDEX "idx_tag_assignment_tag_id" ON "tag_assignment" USING btree ("tag_id");--> statement-breakpoint
CREATE INDEX "idx_tag_assignment_course_id" ON "tag_assignment" USING btree ("course_id");--> statement-breakpoint
CREATE INDEX "idx_tag_group_organization_id" ON "tag_group" USING btree ("organization_id");--> statement-breakpoint
CREATE UNIQUE INDEX "organizationmember_org_profile_unique" ON "organizationmember" USING btree ("organization_id","profile_id") WHERE "organizationmember"."profile_id" IS NOT NULL;--> statement-breakpoint
CREATE UNIQUE INDEX "organizationmember_org_email_unique" ON "organizationmember" USING btree ("organization_id","email") WHERE "organizationmember"."email" IS NOT NULL;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -29,34 +29,6 @@
"when": 1769931903954,
"tag": "0003_fancy_harry_osborn",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1769982690239,
"tag": "0004_medical_true_believers",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1770622627497,
"tag": "0005_outgoing_doomsday",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1771176910627,
"tag": "0006_modern_the_initiative",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1774154467188,
"tag": "0007_rainy_vivisector",
"breakpoints": true
}
]
}
@@ -0,0 +1,96 @@
import { and, desc, eq, inArray } from 'drizzle-orm';
import * as schema from '@db/schema';
import { db } from '@db/drizzle';
/** Cap on how many documents we keep per conversation. Older ones get pruned on insert. */
const MAX_DOCUMENTS_PER_CONVERSATION = 10;
export interface ChatDocumentRecord {
id: string;
conversationId: string;
courseId: string;
userId: string;
assetId: string | null;
fileName: string;
mimeType: string;
text: string;
wordCount: number;
pageCount: number | null;
createdAt: string;
}
export async function createChatDocument(record: {
id: string;
conversationId: string;
courseId: string;
userId: string;
assetId: string | null;
fileName: string;
mimeType: string;
text: string;
wordCount: number;
pageCount: number | null;
}): Promise<void> {
try {
await db.transaction(async (tx) => {
await tx.insert(schema.aiChatDocument).values(record);
const all = await tx
.select({ id: schema.aiChatDocument.id })
.from(schema.aiChatDocument)
.where(eq(schema.aiChatDocument.conversationId, record.conversationId))
.orderBy(desc(schema.aiChatDocument.createdAt));
if (all.length > MAX_DOCUMENTS_PER_CONVERSATION) {
const toDelete = all.slice(MAX_DOCUMENTS_PER_CONVERSATION).map((row) => row.id);
await tx.delete(schema.aiChatDocument).where(inArray(schema.aiChatDocument.id, toDelete));
}
});
} catch (error) {
console.error('createChatDocument error:', error);
throw new Error('Failed to persist chat document');
}
}
export async function getChatDocument(documentId: string): Promise<ChatDocumentRecord | null> {
try {
const [row] = await db
.select()
.from(schema.aiChatDocument)
.where(eq(schema.aiChatDocument.id, documentId))
.limit(1);
return row ?? null;
} catch (error) {
console.error('getChatDocument error:', error);
throw new Error('Failed to fetch chat document');
}
}
export async function getChatDocumentsByIds(documentIds: string[]): Promise<ChatDocumentRecord[]> {
if (documentIds.length === 0) return [];
try {
return await db.select().from(schema.aiChatDocument).where(inArray(schema.aiChatDocument.id, documentIds));
} catch (error) {
console.error('getChatDocumentsByIds error:', error);
throw new Error('Failed to fetch chat documents');
}
}
export async function listChatDocumentsByConversation(
conversationId: string,
userId: string
): Promise<ChatDocumentRecord[]> {
try {
return await db
.select()
.from(schema.aiChatDocument)
.where(and(eq(schema.aiChatDocument.conversationId, conversationId), eq(schema.aiChatDocument.userId, userId)))
.orderBy(desc(schema.aiChatDocument.createdAt));
} catch (error) {
console.error('listChatDocumentsByConversation error:', error);
throw new Error('Failed to list chat documents');
}
}
@@ -0,0 +1,158 @@
import { and, eq, desc } from 'drizzle-orm';
import * as schema from '@db/schema';
import { db } from '@db/drizzle';
const MAX_CONVERSATIONS_PER_COURSE = 50;
export interface ChatConversationSummary {
id: string;
title: string | null;
createdAt: string;
updatedAt: string;
}
// ─── List all conversations for a user in a course ───────────────────────────
export async function listChatConversations(courseId: string, userId: string): Promise<ChatConversationSummary[]> {
try {
const rows = await db
.select({
id: schema.aiChatConversation.id,
title: schema.aiChatConversation.title,
createdAt: schema.aiChatConversation.createdAt,
updatedAt: schema.aiChatConversation.updatedAt
})
.from(schema.aiChatConversation)
.where(and(eq(schema.aiChatConversation.courseId, courseId), eq(schema.aiChatConversation.userId, userId)))
.orderBy(desc(schema.aiChatConversation.updatedAt))
.limit(MAX_CONVERSATIONS_PER_COURSE);
return rows;
} catch (error) {
console.error('listChatConversations error:', error);
throw new Error('Failed to list chat conversations');
}
}
// ─── Get a single conversation with messages ─────────────────────────────────
export async function getChatConversation(conversationId: string, userId: string) {
try {
const [row] = await db
.select()
.from(schema.aiChatConversation)
.where(and(eq(schema.aiChatConversation.id, conversationId), eq(schema.aiChatConversation.userId, userId)))
.limit(1);
return row ?? null;
} catch (error) {
console.error('getChatConversation error:', error);
throw new Error('Failed to fetch chat conversation');
}
}
// ─── Create a new conversation ───────────────────────────────────────────────
export async function createChatConversation(
courseId: string,
userId: string,
title?: string
): Promise<{ id: string; title: string | null }> {
try {
const [row] = await db
.insert(schema.aiChatConversation)
.values({
courseId,
userId,
title: title || 'New conversation',
messages: []
})
.returning({ id: schema.aiChatConversation.id, title: schema.aiChatConversation.title });
return row;
} catch (error) {
console.error('createChatConversation error:', error);
throw new Error('Failed to create chat conversation');
}
}
// ─── Save messages to a conversation ─────────────────────────────────────────
export async function saveChatMessages(conversationId: string, userId: string, messages: unknown[], title?: string) {
try {
const updateData: Record<string, unknown> = {
messages,
updatedAt: new Date().toISOString()
};
if (title) {
updateData.title = title;
}
await db
.update(schema.aiChatConversation)
.set(updateData)
.where(and(eq(schema.aiChatConversation.id, conversationId), eq(schema.aiChatConversation.userId, userId)));
} catch (error) {
console.error('saveChatMessages error:', error);
throw new Error('Failed to save chat messages');
}
}
// ─── Delete a conversation ───────────────────────────────────────────────────
export async function deleteChatConversation(conversationId: string, userId: string) {
try {
await db
.delete(schema.aiChatConversation)
.where(and(eq(schema.aiChatConversation.id, conversationId), eq(schema.aiChatConversation.userId, userId)));
} catch (error) {
console.error('deleteChatConversation error:', error);
throw new Error('Failed to delete chat conversation');
}
}
// ─── Update conversation title ───────────────────────────────────────────────
export async function updateConversationTitle(conversationId: string, userId: string, title: string): Promise<void> {
try {
await db
.update(schema.aiChatConversation)
.set({ title, updatedAt: new Date().toISOString() })
.where(and(eq(schema.aiChatConversation.id, conversationId), eq(schema.aiChatConversation.userId, userId)));
} catch (error) {
console.error('updateConversationTitle error:', error);
throw new Error('Failed to update conversation title');
}
}
// ─── Legacy compat (keep old exports working during migration) ───────────────
export async function getChatHistory(courseId: string, userId: string) {
const conversations = await listChatConversations(courseId, userId);
if (conversations.length === 0) return [];
const latestConversation = await getChatConversation(conversations[0].id, userId);
return (latestConversation?.messages ?? []) as unknown[];
}
export async function saveChatHistory(courseId: string, userId: string, messages: unknown[]) {
const conversations = await listChatConversations(courseId, userId);
if (conversations.length > 0) {
await saveChatMessages(conversations[0].id, userId, messages);
} else {
const newConversation = await createChatConversation(courseId, userId);
await saveChatMessages(newConversation.id, userId, messages);
}
}
export async function deleteChatHistory(courseId: string, userId: string) {
const conversations = await listChatConversations(courseId, userId);
for (const conversation of conversations) {
await deleteChatConversation(conversation.id, userId);
}
}
+2
View File
@@ -0,0 +1,2 @@
export * from './chat-history';
export * from './chat-document';
@@ -0,0 +1,66 @@
import * as schema from '@db/schema';
import { and, desc, eq, ilike, or, sql } from 'drizzle-orm';
import { ROLE } from '@cio/utils/constants';
import { db } from '@db/drizzle';
export interface TSearchOrgAudienceMember {
memberId: number;
profileId: string | null;
name: string;
email: string;
avatarUrl: string | null;
createdAt: string;
}
export async function searchOrgAudience(
orgId: string,
search: string,
limit: number
): Promise<TSearchOrgAudienceMember[]> {
try {
const searchValue = `%${search.trim()}%`;
const audienceNameSql = sql<string>`COALESCE(NULLIF(${schema.profile.fullname}, ''), ${schema.profile.email}, ${schema.organizationmember.email})`;
const audienceEmailSql = sql<string>`COALESCE(${schema.profile.email}, ${schema.organizationmember.email})`;
const audienceCreatedAtSql = sql<string>`COALESCE(${schema.profile.createdAt}, ${schema.organizationmember.createdAt})`;
const rows = await db
.select({
memberId: schema.organizationmember.id,
profileId: schema.profile.id,
name: audienceNameSql.as('name'),
email: audienceEmailSql.as('email'),
avatarUrl: schema.profile.avatarUrl,
createdAt: audienceCreatedAtSql.as('createdAt')
})
.from(schema.organizationmember)
.leftJoin(schema.profile, eq(schema.organizationmember.profileId, schema.profile.id))
.where(
and(
eq(schema.organizationmember.organizationId, orgId),
eq(schema.organizationmember.roleId, ROLE.STUDENT),
or(
ilike(schema.profile.fullname, searchValue),
ilike(schema.profile.email, searchValue),
ilike(schema.organizationmember.email, searchValue)
)
)
)
.orderBy(desc(audienceCreatedAtSql), desc(schema.organizationmember.id))
.limit(limit);
return rows.map((row) => {
const email = row.email?.trim() ?? '';
const name = row.name?.trim() || (email.includes('@') ? email.split('@')[0] : email) || '';
return {
...row,
name,
email
};
});
} catch (error) {
console.error('searchOrgAudience error:', error);
throw new Error(`Failed to search org audience: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
@@ -0,0 +1 @@
export * from './audience';
@@ -0,0 +1,474 @@
import * as schema from '@db/schema';
import { ROLE } from '@cio/utils/constants';
import type {
TCourse,
TCourseCertificateIssue,
TCourseCompletionNotificationEvent,
TCourseCompletionRecord,
TGroupmember,
TNewCourseCertificateIssue,
TNewCourseCompletionNotificationEvent,
TNewCourseCompletionRecord
} from '@db/types';
import { and, asc, desc, eq, inArray, isNotNull, lte, or, sql } from 'drizzle-orm';
import { db, type DbOrTxClient } from '@db/drizzle';
type CourseMemberProfile = {
id: string;
fullname: string | null;
username: string | null;
avatarUrl: string | null;
email: string | null;
};
export type CourseComplianceCurrentRow = {
member: TGroupmember;
profile: CourseMemberProfile | null;
record: TCourseCompletionRecord | null;
};
export type CourseComplianceReminderRow = {
course: TCourse;
member: TGroupmember;
profile: CourseMemberProfile | null;
record: TCourseCompletionRecord;
};
export async function getStudentCourseMembersForCompliance(
courseId: string,
profileIds?: string[]
): Promise<Array<{ member: TGroupmember; profile: CourseMemberProfile | null }>> {
try {
const conditions = [
eq(schema.course.id, courseId),
eq(schema.groupmember.roleId, ROLE.STUDENT),
isNotNull(schema.groupmember.profileId)
];
if (profileIds && profileIds.length > 0) {
conditions.push(inArray(schema.groupmember.profileId, profileIds));
}
const result = await db
.select({
member: schema.groupmember,
profile: {
id: schema.profile.id,
fullname: schema.profile.fullname,
username: schema.profile.username,
avatarUrl: schema.profile.avatarUrl,
email: schema.profile.email
}
})
.from(schema.groupmember)
.innerJoin(schema.course, eq(schema.course.groupId, schema.groupmember.groupId))
.leftJoin(schema.profile, eq(schema.groupmember.profileId, schema.profile.id))
.where(and(...conditions))
.orderBy(asc(schema.profile.fullname), asc(schema.groupmember.createdAt));
return result.map((row) => ({
member: row.member,
profile: row.profile ?? null
}));
} catch (error) {
console.error('getStudentCourseMembersForCompliance error:', error);
throw new Error(
`Failed to get student course members for compliance: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function getCourseCurrentComplianceRows(courseId: string): Promise<CourseComplianceCurrentRow[]> {
try {
const latestCycles = db
.select({
courseId: schema.courseCompletionRecord.courseId,
profileId: schema.courseCompletionRecord.profileId,
latestCycleNumber: sql<number>`max(${schema.courseCompletionRecord.cycleNumber})`.as('latest_cycle_number')
})
.from(schema.courseCompletionRecord)
.where(eq(schema.courseCompletionRecord.courseId, courseId))
.groupBy(schema.courseCompletionRecord.courseId, schema.courseCompletionRecord.profileId)
.as('latest_course_compliance_cycles');
const result = await db
.select({
member: schema.groupmember,
profile: {
id: schema.profile.id,
fullname: schema.profile.fullname,
username: schema.profile.username,
avatarUrl: schema.profile.avatarUrl,
email: schema.profile.email
},
record: schema.courseCompletionRecord
})
.from(schema.groupmember)
.innerJoin(schema.course, eq(schema.course.groupId, schema.groupmember.groupId))
.leftJoin(schema.profile, eq(schema.groupmember.profileId, schema.profile.id))
.leftJoin(
latestCycles,
and(eq(latestCycles.courseId, schema.course.id), eq(latestCycles.profileId, schema.groupmember.profileId))
)
.leftJoin(
schema.courseCompletionRecord,
and(
eq(schema.courseCompletionRecord.courseId, schema.course.id),
eq(schema.courseCompletionRecord.profileId, schema.groupmember.profileId),
eq(schema.courseCompletionRecord.cycleNumber, latestCycles.latestCycleNumber)
)
)
.where(
and(
eq(schema.course.id, courseId),
eq(schema.groupmember.roleId, ROLE.STUDENT),
isNotNull(schema.groupmember.profileId)
)
)
.orderBy(asc(schema.profile.fullname), asc(schema.groupmember.createdAt));
return result.map((row) => ({
member: row.member,
profile: row.profile ?? null,
record: row.record ?? null
}));
} catch (error) {
console.error('getCourseCurrentComplianceRows error:', error);
throw new Error(
`Failed to get course current compliance rows: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function getCourseComplianceHistoryRows(
courseId: string,
profileId: string
): Promise<{
member: TGroupmember | null;
profile: CourseMemberProfile | null;
records: TCourseCompletionRecord[];
}> {
try {
const [memberRow, records] = await Promise.all([
db
.select({
member: schema.groupmember,
profile: {
id: schema.profile.id,
fullname: schema.profile.fullname,
username: schema.profile.username,
avatarUrl: schema.profile.avatarUrl,
email: schema.profile.email
}
})
.from(schema.groupmember)
.innerJoin(schema.course, eq(schema.course.groupId, schema.groupmember.groupId))
.leftJoin(schema.profile, eq(schema.groupmember.profileId, schema.profile.id))
.where(
and(
eq(schema.course.id, courseId),
eq(schema.groupmember.roleId, ROLE.STUDENT),
eq(schema.groupmember.profileId, profileId)
)
)
.limit(1),
db
.select()
.from(schema.courseCompletionRecord)
.where(
and(
eq(schema.courseCompletionRecord.courseId, courseId),
eq(schema.courseCompletionRecord.profileId, profileId)
)
)
.orderBy(desc(schema.courseCompletionRecord.cycleNumber))
]);
return {
member: memberRow[0]?.member ?? null,
profile: memberRow[0]?.profile ?? null,
records
};
} catch (error) {
console.error('getCourseComplianceHistoryRows error:', error);
throw new Error(
`Failed to get course compliance history rows: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function getLatestComplianceRecordsByProfiles(
courseId: string,
profileIds: string[]
): Promise<TCourseCompletionRecord[]> {
try {
if (profileIds.length === 0) {
return [];
}
const latestCycles = db
.select({
courseId: schema.courseCompletionRecord.courseId,
profileId: schema.courseCompletionRecord.profileId,
latestCycleNumber: sql<number>`max(${schema.courseCompletionRecord.cycleNumber})`.as('latest_cycle_number')
})
.from(schema.courseCompletionRecord)
.where(
and(
eq(schema.courseCompletionRecord.courseId, courseId),
inArray(schema.courseCompletionRecord.profileId, profileIds)
)
)
.groupBy(schema.courseCompletionRecord.courseId, schema.courseCompletionRecord.profileId)
.as('latest_course_compliance_cycles_by_profile');
return await db
.select({
id: schema.courseCompletionRecord.id,
courseId: schema.courseCompletionRecord.courseId,
groupMemberId: schema.courseCompletionRecord.groupMemberId,
profileId: schema.courseCompletionRecord.profileId,
cycleNumber: schema.courseCompletionRecord.cycleNumber,
status: schema.courseCompletionRecord.status,
dueDate: schema.courseCompletionRecord.dueDate,
startedAt: schema.courseCompletionRecord.startedAt,
completedAt: schema.courseCompletionRecord.completedAt,
validUntil: schema.courseCompletionRecord.validUntil,
expiredAt: schema.courseCompletionRecord.expiredAt,
score: schema.courseCompletionRecord.score,
attempts: schema.courseCompletionRecord.attempts,
timeSpentMinutes: schema.courseCompletionRecord.timeSpentMinutes,
waivedBy: schema.courseCompletionRecord.waivedBy,
waiverReason: schema.courseCompletionRecord.waiverReason,
waiverExpiresAt: schema.courseCompletionRecord.waiverExpiresAt,
createdAt: schema.courseCompletionRecord.createdAt,
updatedAt: schema.courseCompletionRecord.updatedAt
})
.from(schema.courseCompletionRecord)
.innerJoin(
latestCycles,
and(
eq(latestCycles.courseId, schema.courseCompletionRecord.courseId),
eq(latestCycles.profileId, schema.courseCompletionRecord.profileId),
eq(latestCycles.latestCycleNumber, schema.courseCompletionRecord.cycleNumber)
)
);
} catch (error) {
console.error('getLatestComplianceRecordsByProfiles error:', error);
throw new Error(
`Failed to get latest compliance records by profiles: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function createCourseCompletionRecord(
values: TNewCourseCompletionRecord,
dbClient: DbOrTxClient = db
): Promise<TCourseCompletionRecord> {
try {
const [created] = await dbClient.insert(schema.courseCompletionRecord).values(values).returning();
if (!created) {
throw new Error('Failed to create course completion record');
}
return created;
} catch (error) {
console.error('createCourseCompletionRecord error:', error);
throw new Error(
`Failed to create course completion record: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function updateCourseCompletionRecord(
recordId: string,
values: Partial<TNewCourseCompletionRecord>,
dbClient: DbOrTxClient = db
): Promise<TCourseCompletionRecord | null> {
try {
const [updated] = await dbClient
.update(schema.courseCompletionRecord)
.set({
...values,
updatedAt: new Date().toISOString()
})
.where(eq(schema.courseCompletionRecord.id, recordId))
.returning();
return updated ?? null;
} catch (error) {
console.error('updateCourseCompletionRecord error:', error);
throw new Error(
`Failed to update course completion record: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function createCourseCompletionNotificationEvent(
values: TNewCourseCompletionNotificationEvent,
dbClient: DbOrTxClient = db
): Promise<TCourseCompletionNotificationEvent | null> {
try {
const [created] = await dbClient
.insert(schema.courseCompletionNotificationEvent)
.values(values)
.onConflictDoNothing({
target: [
schema.courseCompletionNotificationEvent.courseCompletionRecordId,
schema.courseCompletionNotificationEvent.channel,
schema.courseCompletionNotificationEvent.eventType
]
})
.returning();
return created ?? null;
} catch (error) {
console.error('createCourseCompletionNotificationEvent error:', error);
throw new Error(
`Failed to create course completion notification event: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function createCourseCertificateIssue(
values: TNewCourseCertificateIssue,
dbClient: DbOrTxClient = db
): Promise<TCourseCertificateIssue | null> {
try {
const [created] = await dbClient
.insert(schema.courseCertificateIssue)
.values(values)
.onConflictDoNothing({
target: [schema.courseCertificateIssue.courseCompletionRecordId]
})
.returning();
return created ?? null;
} catch (error) {
console.error('createCourseCertificateIssue error:', error);
throw new Error(
`Failed to create course certificate issue: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function updateCourseCertificateIssueStatusByRecordId(
courseCompletionRecordId: string,
status: string,
dbClient: DbOrTxClient = db
): Promise<TCourseCertificateIssue | null> {
try {
const [updated] = await dbClient
.update(schema.courseCertificateIssue)
.set({
status
})
.where(eq(schema.courseCertificateIssue.courseCompletionRecordId, courseCompletionRecordId))
.returning();
return updated ?? null;
} catch (error) {
console.error('updateCourseCertificateIssueStatusByRecordId error:', error);
throw new Error(
`Failed to update course certificate issue status by record ID: ${
error instanceof Error ? error.message : 'Unknown error'
}`
);
}
}
export async function listComplianceRecordsReadyForExpiry(nowIso: string): Promise<TCourseCompletionRecord[]> {
try {
return await db
.select({
record: schema.courseCompletionRecord
})
.from(schema.courseCompletionRecord)
.innerJoin(schema.course, eq(schema.course.id, schema.courseCompletionRecord.courseId))
.where(
and(
eq(schema.course.type, 'COMPLIANCE'),
isNotNull(schema.courseCompletionRecord.completedAt),
isNotNull(schema.courseCompletionRecord.validUntil),
lte(schema.courseCompletionRecord.validUntil, nowIso),
sql`${schema.courseCompletionRecord.expiredAt} IS NULL`
)
)
.orderBy(asc(schema.courseCompletionRecord.validUntil))
.then((rows) => rows.map((row) => row.record));
} catch (error) {
console.error('listComplianceRecordsReadyForExpiry error:', error);
throw new Error(
`Failed to list compliance records ready for expiry: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function listLatestComplianceRecordsForReminderScan(): Promise<CourseComplianceReminderRow[]> {
try {
const latestCycles = db
.select({
courseId: schema.courseCompletionRecord.courseId,
profileId: schema.courseCompletionRecord.profileId,
latestCycleNumber: sql<number>`max(${schema.courseCompletionRecord.cycleNumber})`.as('latest_cycle_number')
})
.from(schema.courseCompletionRecord)
.groupBy(schema.courseCompletionRecord.courseId, schema.courseCompletionRecord.profileId)
.as('latest_course_compliance_cycles');
const result = await db
.select({
course: schema.course,
member: schema.groupmember,
profile: {
id: schema.profile.id,
fullname: schema.profile.fullname,
username: schema.profile.username,
avatarUrl: schema.profile.avatarUrl,
email: schema.profile.email
},
record: schema.courseCompletionRecord
})
.from(schema.courseCompletionRecord)
.innerJoin(
latestCycles,
and(
eq(latestCycles.courseId, schema.courseCompletionRecord.courseId),
eq(latestCycles.profileId, schema.courseCompletionRecord.profileId),
eq(latestCycles.latestCycleNumber, schema.courseCompletionRecord.cycleNumber)
)
)
.innerJoin(schema.course, eq(schema.course.id, schema.courseCompletionRecord.courseId))
.innerJoin(schema.groupmember, eq(schema.groupmember.id, schema.courseCompletionRecord.groupMemberId))
.leftJoin(schema.profile, eq(schema.profile.id, schema.courseCompletionRecord.profileId))
.where(
and(
eq(schema.course.type, 'COMPLIANCE'),
or(
eq(schema.courseCompletionRecord.status, 'not_started'),
eq(schema.courseCompletionRecord.status, 'in_progress'),
eq(schema.courseCompletionRecord.status, 'expiring_soon'),
eq(schema.courseCompletionRecord.status, 'in_grace_period'),
eq(schema.courseCompletionRecord.status, 'non_compliant')
),
sql`${schema.courseCompletionRecord.completedAt} IS NULL`
)
)
.orderBy(asc(schema.courseCompletionRecord.dueDate));
return result.map((row) => ({
course: row.course,
member: row.member,
profile: row.profile ?? null,
record: row.record
}));
} catch (error) {
console.error('listLatestComplianceRecordsForReminderScan error:', error);
throw new Error(
`Failed to list latest compliance records for reminder scan: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
+144 -5
View File
@@ -42,6 +42,10 @@ export interface TAdminCourse extends TBaseCourse {
export interface TStudentCourse extends TBaseCourse {
progressRate: number;
exercisesCompleted: number;
complianceStatus: string | null;
complianceCycleNumber: number | null;
complianceDueDate: string | null;
complianceValidUntil: string | null;
}
/**
@@ -257,8 +261,7 @@ export const getCoursesBySiteNameForSetup = async (siteName: string) => {
.from(schema.course)
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
.innerJoin(schema.organization, eq(schema.group.organizationId, schema.organization.id))
.where(eq(schema.organization.siteName, siteName))
.limit(1);
.where(eq(schema.organization.siteName, siteName));
return result.map((row) => ({
...row.course
@@ -592,6 +595,16 @@ export async function getCourseProgress(
/** Course + org fields needed for certification email and threshold rules */
export type TCourseCertificationRow = {
type: string | null;
compliance: {
retakeIntervalMonths: number;
gracePeriodDays?: number;
reminderDaysBefore?: number[];
isMandatory?: boolean;
framework?: 'HIPAA' | 'OSHA' | 'SOX' | 'GDPR' | 'PCI_DSS' | 'FERPA' | 'ISO' | 'CUSTOM' | null;
maxRetakeAttempts?: number | null;
passingScore?: number;
} | null;
certificate: {
isDownloadable?: boolean;
theme?: string;
@@ -610,6 +623,8 @@ export async function getCourseCertificationRow(courseId: string): Promise<TCour
try {
const [row] = await db
.select({
type: schema.course.type,
compliance: schema.course.compliance,
certificate: schema.course.certificate,
title: schema.course.title,
orgSiteName: schema.organization.siteName,
@@ -761,6 +776,99 @@ export const getOrgCourses = async ({
}
};
export interface TSearchOrgCourse {
id: string;
title: string;
slug: string | null;
description: string;
updatedAt: string | null;
}
export async function searchOrgCourses(orgId: string, search: string, limit: number): Promise<TSearchOrgCourse[]> {
try {
const searchValue = `%${search.trim()}%`;
return await db
.select({
id: schema.course.id,
title: schema.course.title,
slug: schema.course.slug,
description: schema.course.description,
updatedAt: schema.course.updatedAt
})
.from(schema.course)
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
.where(
and(
eq(schema.group.organizationId, orgId),
eq(schema.course.status, 'ACTIVE'),
or(ilike(schema.course.title, searchValue), ilike(schema.course.description, searchValue))
)
)
.orderBy(desc(schema.course.updatedAt))
.limit(limit);
} catch (error) {
console.error('searchOrgCourses error:', error);
throw new Error(`Failed to search org courses: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function searchLmsCourses(
orgId: string,
profileId: string,
search: string,
limit: number
): Promise<TSearchOrgCourse[]> {
try {
const searchValue = `%${search.trim()}%`;
const result = await db
.select({
id: schema.course.id,
title: schema.course.title,
slug: schema.course.slug,
description: schema.course.description,
updatedAt: schema.course.updatedAt
})
.from(schema.course)
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
.leftJoin(
schema.groupmember,
and(eq(schema.group.id, schema.groupmember.groupId), eq(schema.groupmember.profileId, profileId))
)
.leftJoin(schema.programCourse, eq(schema.course.id, schema.programCourse.courseId))
.leftJoin(
schema.program,
and(
eq(schema.programCourse.programId, schema.program.id),
eq(schema.program.organizationId, orgId),
eq(schema.program.status, 'ACTIVE')
)
)
.leftJoin(
schema.programMember,
and(eq(schema.program.id, schema.programMember.programId), eq(schema.programMember.profileId, profileId))
)
.where(
and(
eq(schema.group.organizationId, orgId),
eq(schema.course.status, 'ACTIVE'),
eq(schema.course.isPublished, true),
or(isNotNull(schema.groupmember.id), isNotNull(schema.programMember.id)),
or(ilike(schema.course.title, searchValue), ilike(schema.course.description, searchValue))
)
)
.groupBy(schema.course.id)
.orderBy(desc(schema.course.updatedAt))
.limit(limit);
return result;
} catch (error) {
console.error('searchLmsCourses error:', error);
throw new Error(`Failed to search LMS courses: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
interface GetEnrolledCoursesOptions {
/** Organization ID (required) */
orgId: string;
@@ -780,6 +888,17 @@ export const getEnrolledCourses = async ({
profileId
}: GetEnrolledCoursesOptions): Promise<TStudentCourse[]> => {
try {
const latestComplianceCycles = db
.select({
courseId: schema.courseCompletionRecord.courseId,
profileId: schema.courseCompletionRecord.profileId,
latestCycleNumber: sql<number>`MAX(${schema.courseCompletionRecord.cycleNumber})`.as('latest_cycle_number')
})
.from(schema.courseCompletionRecord)
.where(eq(schema.courseCompletionRecord.profileId, profileId))
.groupBy(schema.courseCompletionRecord.courseId, schema.courseCompletionRecord.profileId)
.as('latest_enrolled_course_compliance_cycles');
// Single query covering both access paths:
// 1. Direct enrollment: student has a groupmember row for the course's group
// 2. Program membership: student is a program_member of a program that contains the course
@@ -817,7 +936,11 @@ export const getEnrolledCourses = async ({
or(eq(sql`ex.course_id`, schema.course.id), eq(sql`el.course_id`, schema.course.id)),
sql`gm.profile_id = ${sql.raw(`'${profileId}'::uuid`)}`
)}
)`.as('exercises_completed')
)`.as('exercises_completed'),
complianceStatus: schema.courseCompletionRecord.status,
complianceCycleNumber: schema.courseCompletionRecord.cycleNumber,
complianceDueDate: schema.courseCompletionRecord.dueDate,
complianceValidUntil: schema.courseCompletionRecord.validUntil
})
.from(schema.course)
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
@@ -825,6 +948,18 @@ export const getEnrolledCourses = async ({
schema.groupmember,
and(eq(schema.group.id, schema.groupmember.groupId), eq(schema.groupmember.profileId, profileId))
)
.leftJoin(
latestComplianceCycles,
and(eq(latestComplianceCycles.courseId, schema.course.id), eq(latestComplianceCycles.profileId, profileId))
)
.leftJoin(
schema.courseCompletionRecord,
and(
eq(schema.courseCompletionRecord.courseId, schema.course.id),
eq(schema.courseCompletionRecord.profileId, profileId),
eq(schema.courseCompletionRecord.cycleNumber, latestComplianceCycles.latestCycleNumber)
)
)
.leftJoin(schema.programCourse, eq(schema.course.id, schema.programCourse.courseId))
.leftJoin(
schema.program,
@@ -847,7 +982,7 @@ export const getEnrolledCourses = async ({
or(isNotNull(schema.groupmember.id), isNotNull(schema.programMember.id))
)
)
.groupBy(schema.course.id)
.groupBy(schema.course.id, schema.courseCompletionRecord.id)
.orderBy(desc(schema.course.createdAt));
return result.map((row) => ({
@@ -855,7 +990,11 @@ export const getEnrolledCourses = async ({
lessonCount: Number(row.lessonCount),
progressRate: Number(row.progressRate),
exerciseCount: Number(row.exerciseCount),
exercisesCompleted: Number(row.exercisesCompleted)
exercisesCompleted: Number(row.exercisesCompleted),
complianceStatus: row.complianceStatus ?? null,
complianceCycleNumber: row.complianceCycleNumber ?? null,
complianceDueDate: row.complianceDueDate ?? null,
complianceValidUntil: row.complianceValidUntil ?? null
}));
} catch (error) {
console.error('getEnrolledCourses error:', error);
+1
View File
@@ -1,5 +1,6 @@
export * from './course';
export * from './course';
export * from './compliance';
export * from './content';
export * from './people';
export * from './invite';
+64
View File
@@ -1,19 +1,24 @@
import {
analyticsLoginEvents,
and,
course,
dashOrgStats,
db,
desc,
eq,
gte,
group,
groupmember,
isNotNull,
lesson,
lessonCompletion,
organizationmember,
profile,
sql
} from '@db/drizzle';
import { ROLE } from '@cio/utils/constants';
export function getCourseStats(orgId: string) {
const subquery = db
.select({
@@ -135,3 +140,62 @@ export function getRecentCertifications(orgId: string) {
export function getDashOrgStats(orgId: string) {
return db.select().from(dashOrgStats).where(eq(dashOrgStats.orgId, orgId));
}
/**
* Returns login counts grouped by day-of-week (0 = Sunday … 6 = Saturday) for
* students of the given organization over the last `days` days.
*
* Each row in analytics_login_events is already one-per-user-per-day, so the
* counts represent distinct active students per weekday — the chart shows
* "most active day," not raw login volume.
*/
export function getOrgStudentLoginsByDayOfWeek(orgId: string, days: number) {
const since = sql`CURRENT_DATE - ${sql.raw(`${days}`)}::int`;
return db
.select({
dayOfWeek: sql<number>`EXTRACT(DOW FROM ${analyticsLoginEvents.loggedInDate})::int`.as('day_of_week'),
count: sql<number>`COUNT(*)::int`.as('count')
})
.from(analyticsLoginEvents)
.innerJoin(organizationmember, eq(organizationmember.profileId, analyticsLoginEvents.userId))
.where(
and(
eq(organizationmember.organizationId, orgId),
eq(organizationmember.roleId, ROLE.STUDENT),
gte(analyticsLoginEvents.loggedInDate, since)
)
)
.groupBy(sql`day_of_week`)
.orderBy(sql`day_of_week`);
}
function getDateKey(date: Date) {
return date.toISOString().slice(0, 10);
}
export async function getUserLoginStreak(userId: string) {
const rows = await db
.select({
loggedInDate: analyticsLoginEvents.loggedInDate
})
.from(analyticsLoginEvents)
.where(eq(analyticsLoginEvents.userId, userId))
.orderBy(desc(analyticsLoginEvents.loggedInDate))
.limit(90);
const loggedInDates = new Set(rows.map((row) => row.loggedInDate));
const cursorDate = new Date();
if (!loggedInDates.has(getDateKey(cursorDate))) {
cursorDate.setDate(cursorDate.getDate() - 1);
}
let daysActive = 0;
while (loggedInDates.has(getDateKey(cursorDate))) {
daysActive += 1;
cursorDate.setDate(cursorDate.getDate() - 1);
}
return { daysActive };
}
@@ -198,6 +198,7 @@ export async function updateUngroupedExercisesSectionId(
export async function deleteExercise(exerciseId: string, dbClient: DbOrTxClient = db) {
try {
const [deleted] = await dbClient.delete(schema.exercise).where(eq(schema.exercise.id, exerciseId)).returning();
console.log('Deleted exercise:', deleted);
return deleted || null;
} catch (error) {
console.error('deleteExercise error:', error);
+2
View File
@@ -1,6 +1,7 @@
export * from './organization';
export * from './auth';
export * from './attendance';
export * from './audience';
export * from './assets';
export * from './course';
export * from './group';
@@ -13,3 +14,4 @@ export * from './submission';
export * from './tag';
export * from './course-import';
export * from './program';
export * from './widget';
@@ -367,6 +367,33 @@ export const getUserOrgRole = async (orgId: string, profileId: string): Promise<
return result.length > 0 ? Number(result[0].roleId) : null;
};
/**
* Gets all org memberships for a user as { [orgId]: roleId }.
* Used to attach org roles to the Better Auth session so middleware can
* read membership/role from the session cookie cache instead of hitting the DB.
*/
export const getUserOrgRolesMap = async (profileId: string): Promise<Record<string, number>> => {
try {
const rows = await db
.select({
organizationId: schema.organizationmember.organizationId,
roleId: schema.organizationmember.roleId
})
.from(schema.organizationmember)
.where(eq(schema.organizationmember.profileId, profileId));
const map: Record<string, number> = {};
for (const row of rows) {
map[row.organizationId] = Number(row.roleId);
}
return map;
} catch (error) {
console.error('getUserOrgRolesMap error:', error);
throw new Error('Failed to fetch user org roles map');
}
};
/**
* Checks if a user is a team member (ADMIN or TUTOR) of an organization
* @param orgId Organization ID
+72 -1
View File
@@ -1,6 +1,6 @@
import * as schema from '@db/schema';
import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm';
import { and, asc, desc, eq, ilike, inArray, or, sql } from 'drizzle-orm';
import { ROLE } from '@cio/utils/constants';
import { db } from '@db/drizzle';
@@ -118,6 +118,77 @@ export async function getProgramsByOrg(
}
}
export interface TSearchOrgProgram {
id: string;
name: string;
description: string | null;
status: string;
updatedAt: string | null;
}
export async function searchOrgPrograms(orgId: string, search: string, limit: number): Promise<TSearchOrgProgram[]> {
try {
const searchValue = `%${search.trim()}%`;
return await db
.select({
id: schema.program.id,
name: schema.program.name,
description: schema.program.description,
status: schema.program.status,
updatedAt: schema.program.updatedAt
})
.from(schema.program)
.where(
and(
eq(schema.program.organizationId, orgId),
eq(schema.program.status, 'ACTIVE'),
or(ilike(schema.program.name, searchValue), ilike(schema.program.description, searchValue))
)
)
.orderBy(desc(schema.program.updatedAt))
.limit(limit);
} catch (error) {
console.error('searchOrgPrograms error:', error);
throw new Error(`Failed to search org programs: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function searchLmsPrograms(
orgId: string,
profileId: string,
search: string,
limit: number
): Promise<TSearchOrgProgram[]> {
try {
const searchValue = `%${search.trim()}%`;
return await db
.select({
id: schema.program.id,
name: schema.program.name,
description: schema.program.description,
status: schema.program.status,
updatedAt: schema.program.updatedAt
})
.from(schema.programMember)
.innerJoin(schema.program, eq(schema.programMember.programId, schema.program.id))
.where(
and(
eq(schema.programMember.profileId, profileId),
eq(schema.program.organizationId, orgId),
eq(schema.program.status, 'ACTIVE'),
or(ilike(schema.program.name, searchValue), ilike(schema.program.description, searchValue))
)
)
.orderBy(desc(schema.program.updatedAt))
.limit(limit);
} catch (error) {
console.error('searchLmsPrograms error:', error);
throw new Error(`Failed to search LMS programs: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function getExistingProgramMembers(
pairs: Array<{ programId: string; profileId: string }>
): Promise<Set<string>> {
+62 -1
View File
@@ -1,5 +1,5 @@
import type { TNewTag, TNewTagGroup, TTag, TTagAssignment, TTagGroup } from '@db/types';
import { and, asc, eq, inArray, sql } from 'drizzle-orm';
import { and, asc, eq, ilike, inArray, sql } from 'drizzle-orm';
import * as schema from '@db/schema';
import { db } from '@db/drizzle';
@@ -19,6 +19,26 @@ export interface TCourseTagPreview {
color: string;
}
export type TSearchOrgTag =
| {
kind: 'group';
id: string;
name: string;
slug: string;
description: string | null;
color: null;
groupName: null;
}
| {
kind: 'tag';
id: string;
name: string;
slug: string;
description: string | null;
color: string;
groupName: string;
};
interface GetTagGroupsWithTagsOptions {
onlyPublishedCourses?: boolean;
}
@@ -151,6 +171,47 @@ export async function getTagsByIds(orgId: string, tagIds: string[]): Promise<TTa
}
}
export async function searchOrgTagsAndGroups(orgId: string, search: string, limit: number): Promise<TSearchOrgTag[]> {
try {
const searchValue = `%${search.trim()}%`;
const [groups, tags] = await Promise.all([
db
.select({
id: schema.tagGroup.id,
name: schema.tagGroup.name,
slug: schema.tagGroup.slug,
description: schema.tagGroup.description
})
.from(schema.tagGroup)
.where(and(eq(schema.tagGroup.organizationId, orgId), ilike(schema.tagGroup.name, searchValue)))
.orderBy(asc(schema.tagGroup.name))
.limit(limit),
db
.select({
id: schema.tag.id,
name: schema.tag.name,
slug: schema.tag.slug,
description: schema.tag.description,
color: schema.tag.color,
groupName: schema.tagGroup.name
})
.from(schema.tag)
.innerJoin(schema.tagGroup, eq(schema.tag.groupId, schema.tagGroup.id))
.where(and(eq(schema.tag.organizationId, orgId), ilike(schema.tag.name, searchValue)))
.orderBy(asc(schema.tag.name))
.limit(limit)
]);
return [
...groups.map((group): TSearchOrgTag => ({ ...group, kind: 'group', color: null, groupName: null })),
...tags.map((tag): TSearchOrgTag => ({ ...tag, kind: 'tag' }))
].slice(0, limit);
} catch (error) {
console.error('searchOrgTagsAndGroups error:', error);
throw new Error('Failed to search tags and tag groups');
}
}
export async function createTagGroup(data: TNewTagGroup): Promise<TTagGroup> {
try {
const [result] = await db.insert(schema.tagGroup).values(data).returning();
+1
View File
@@ -0,0 +1 @@
export * from './widget';
+324
View File
@@ -0,0 +1,324 @@
import * as schema from '@db/schema';
import type {
TNewWidget,
TNewWidgetCourse,
TNewWidgetVersion,
TWidget,
TWidgetCourse,
TWidgetVersion
} from '@db/types';
import { and, asc, desc, eq, ilike, inArray, isNull, sql } from 'drizzle-orm';
import { db } from '@db/drizzle';
export type TWidgetListItem = TWidget & {
courseCount: number;
};
function widgetListItemSelect() {
return {
id: schema.widget.id,
organizationId: schema.widget.organizationId,
name: schema.widget.name,
status: schema.widget.status,
layoutType: schema.widget.layoutType,
selectionMode: schema.widget.selectionMode,
publicKey: schema.widget.publicKey,
config: schema.widget.config,
hasUnpublishedChanges: schema.widget.hasUnpublishedChanges,
latestPublishedVersionId: schema.widget.latestPublishedVersionId,
createdByUserId: schema.widget.createdByUserId,
updatedByUserId: schema.widget.updatedByUserId,
deletedAt: schema.widget.deletedAt,
createdAt: schema.widget.createdAt,
updatedAt: schema.widget.updatedAt,
courseCount: sql<number>`COUNT(DISTINCT ${schema.widgetCourse.courseId})`.as('course_count')
};
}
export async function listWidgetsByOrganization(orgId: string): Promise<TWidgetListItem[]> {
try {
return db
.select(widgetListItemSelect())
.from(schema.widget)
.leftJoin(schema.widgetCourse, eq(schema.widgetCourse.widgetId, schema.widget.id))
.where(and(eq(schema.widget.organizationId, orgId), isNull(schema.widget.deletedAt)))
.groupBy(schema.widget.id)
.orderBy(desc(schema.widget.updatedAt), asc(schema.widget.name));
} catch (error) {
console.error('listWidgetsByOrganization error:', error);
throw new Error('Failed to list widgets');
}
}
export async function searchOrgWidgets(orgId: string, search: string, limit: number): Promise<TWidgetListItem[]> {
try {
const searchValue = `%${search.trim()}%`;
return await db
.select(widgetListItemSelect())
.from(schema.widget)
.leftJoin(schema.widgetCourse, eq(schema.widgetCourse.widgetId, schema.widget.id))
.where(
and(
eq(schema.widget.organizationId, orgId),
isNull(schema.widget.deletedAt),
ilike(schema.widget.name, searchValue)
)
)
.groupBy(schema.widget.id)
.orderBy(desc(schema.widget.updatedAt), asc(schema.widget.name))
.limit(limit);
} catch (error) {
console.error('searchOrgWidgets error:', error);
throw new Error('Failed to search widgets');
}
}
export async function getWidgetListItemById(orgId: string, widgetId: string): Promise<TWidgetListItem | null> {
try {
const [result] = await db
.select(widgetListItemSelect())
.from(schema.widget)
.leftJoin(schema.widgetCourse, eq(schema.widgetCourse.widgetId, schema.widget.id))
.where(
and(eq(schema.widget.organizationId, orgId), eq(schema.widget.id, widgetId), isNull(schema.widget.deletedAt))
)
.groupBy(schema.widget.id)
.limit(1);
return result ?? null;
} catch (error) {
console.error('getWidgetListItemById error:', error);
throw new Error('Failed to get widget list item');
}
}
export async function getWidgetById(orgId: string, widgetId: string): Promise<TWidget | null> {
try {
const [result] = await db
.select()
.from(schema.widget)
.where(
and(eq(schema.widget.organizationId, orgId), eq(schema.widget.id, widgetId), isNull(schema.widget.deletedAt))
)
.limit(1);
return result ?? null;
} catch (error) {
console.error('getWidgetById error:', error);
throw new Error('Failed to get widget');
}
}
export async function getWidgetByPublicKey(publicKey: string): Promise<TWidget | null> {
try {
const [result] = await db
.select()
.from(schema.widget)
.where(and(eq(schema.widget.publicKey, publicKey), isNull(schema.widget.deletedAt)))
.limit(1);
return result ?? null;
} catch (error) {
console.error('getWidgetByPublicKey error:', error);
throw new Error('Failed to get widget by public key');
}
}
export async function createWidget(data: TNewWidget): Promise<TWidget> {
try {
const [result] = await db.insert(schema.widget).values(data).returning();
return result;
} catch (error) {
console.error('createWidget error:', error);
throw new Error('Failed to create widget');
}
}
export async function updateWidget(orgId: string, widgetId: string, data: Partial<TWidget>): Promise<TWidget | null> {
try {
const [result] = await db
.update(schema.widget)
.set({
...data,
updatedAt: new Date().toISOString()
})
.where(
and(eq(schema.widget.organizationId, orgId), eq(schema.widget.id, widgetId), isNull(schema.widget.deletedAt))
)
.returning();
return result ?? null;
} catch (error) {
console.error('updateWidget error:', error);
throw new Error('Failed to update widget');
}
}
export async function archiveWidget(orgId: string, widgetId: string, updatedByUserId: string): Promise<TWidget | null> {
try {
const [result] = await db
.update(schema.widget)
.set({
status: 'ARCHIVED',
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
updatedByUserId
})
.where(
and(eq(schema.widget.organizationId, orgId), eq(schema.widget.id, widgetId), isNull(schema.widget.deletedAt))
)
.returning();
return result ?? null;
} catch (error) {
console.error('archiveWidget error:', error);
throw new Error('Failed to archive widget');
}
}
export async function listWidgetCourses(widgetId: string): Promise<TWidgetCourse[]> {
try {
return db
.select()
.from(schema.widgetCourse)
.where(eq(schema.widgetCourse.widgetId, widgetId))
.orderBy(asc(schema.widgetCourse.order), asc(schema.widgetCourse.createdAt));
} catch (error) {
console.error('listWidgetCourses error:', error);
throw new Error('Failed to list widget courses');
}
}
export async function replaceWidgetCourses(widgetId: string, courseIds: string[]): Promise<TWidgetCourse[]> {
try {
const normalizedCourseIds = Array.from(new Set(courseIds));
return db.transaction(async (tx) => {
await tx.delete(schema.widgetCourse).where(eq(schema.widgetCourse.widgetId, widgetId));
if (normalizedCourseIds.length === 0) {
return [];
}
const rowsToInsert: TNewWidgetCourse[] = normalizedCourseIds.map((courseId, index) => ({
widgetId,
courseId,
order: index
}));
return tx.insert(schema.widgetCourse).values(rowsToInsert).returning();
});
} catch (error) {
console.error('replaceWidgetCourses error:', error);
throw new Error('Failed to replace widget courses');
}
}
export async function getNextWidgetVersion(widgetId: string): Promise<number> {
try {
const [result] = await db
.select({
latestVersion: sql<number>`COALESCE(MAX(${schema.widgetVersion.version}), 0)`.as('latest_version')
})
.from(schema.widgetVersion)
.where(eq(schema.widgetVersion.widgetId, widgetId));
return Number(result?.latestVersion ?? 0) + 1;
} catch (error) {
console.error('getNextWidgetVersion error:', error);
throw new Error('Failed to get next widget version');
}
}
export async function createWidgetVersion(data: TNewWidgetVersion): Promise<TWidgetVersion> {
try {
const [result] = await db.insert(schema.widgetVersion).values(data).returning();
return result;
} catch (error) {
console.error('createWidgetVersion error:', error);
throw new Error('Failed to create widget version');
}
}
export async function listWidgetVersions(orgId: string, widgetId: string): Promise<TWidgetVersion[]> {
try {
return db
.select({ version: schema.widgetVersion })
.from(schema.widgetVersion)
.innerJoin(schema.widget, eq(schema.widgetVersion.widgetId, schema.widget.id))
.where(and(eq(schema.widget.organizationId, orgId), eq(schema.widgetVersion.widgetId, widgetId)))
.orderBy(desc(schema.widgetVersion.version))
.then((rows) => rows.map((row) => row.version));
} catch (error) {
console.error('listWidgetVersions error:', error);
throw new Error('Failed to list widget versions');
}
}
export async function getWidgetVersionById(
orgId: string,
widgetId: string,
versionId: string
): Promise<TWidgetVersion | null> {
try {
const [result] = await db
.select({ version: schema.widgetVersion })
.from(schema.widgetVersion)
.innerJoin(schema.widget, eq(schema.widgetVersion.widgetId, schema.widget.id))
.where(
and(
eq(schema.widget.organizationId, orgId),
eq(schema.widgetVersion.widgetId, widgetId),
eq(schema.widgetVersion.id, versionId)
)
)
.limit(1);
return result?.version ?? null;
} catch (error) {
console.error('getWidgetVersionById error:', error);
throw new Error('Failed to get widget version');
}
}
export async function getPublishedWidgetPayloadByPublicKey(publicKey: string): Promise<unknown | null> {
try {
const [result] = await db
.select({
payloadSnapshot: schema.widgetVersion.payloadSnapshot
})
.from(schema.widget)
.innerJoin(schema.widgetVersion, eq(schema.widget.latestPublishedVersionId, schema.widgetVersion.id))
.where(and(eq(schema.widget.publicKey, publicKey), isNull(schema.widget.deletedAt)))
.limit(1);
return result?.payloadSnapshot ?? null;
} catch (error) {
console.error('getPublishedWidgetPayloadByPublicKey error:', error);
throw new Error('Failed to get published widget payload');
}
}
export async function getWidgetsByIds(orgId: string, widgetIds: string[]): Promise<TWidget[]> {
try {
if (widgetIds.length === 0) {
return [];
}
return db
.select()
.from(schema.widget)
.where(
and(
eq(schema.widget.organizationId, orgId),
inArray(schema.widget.id, widgetIds),
isNull(schema.widget.deletedAt)
)
);
} catch (error) {
console.error('getWidgetsByIds error:', error);
throw new Error('Failed to get widgets by ids');
}
}
+382 -3
View File
@@ -1,6 +1,7 @@
import {
bigint,
boolean,
date,
doublePrecision,
foreignKey,
index,
@@ -22,13 +23,24 @@ import {
import type { AnswerData } from '@cio/question-types';
import { sql } from 'drizzle-orm';
export const courseType = pgEnum('COURSE_TYPE', ['SELF_PACED', 'LIVE_CLASS']);
export const courseType = pgEnum('COURSE_TYPE', ['SELF_PACED', 'LIVE_CLASS', 'COMPLIANCE']);
export const locale = pgEnum('LOCALE', ['en', 'hi', 'fr', 'pt', 'de', 'vi', 'ru', 'es', 'pl', 'da']);
export const plan = pgEnum('PLAN', ['EARLY_ADOPTER', 'ENTERPRISE', 'BASIC']);
export const courseImportSourceType = pgEnum('COURSE_IMPORT_SOURCE_TYPE', ['prompt', 'pdf', 'course']);
export const courseImportDraftStatus = pgEnum('COURSE_IMPORT_DRAFT_STATUS', ['DRAFT', 'PUBLISHED', 'ARCHIVED']);
export const organizationApiKeyType = pgEnum('ORGANIZATION_API_KEY_TYPE', ['mcp', 'api', 'zapier']);
export const automationUsageCategory = pgEnum('AUTOMATION_USAGE_CATEGORY', ['read', 'write', 'publish']);
export const widgetStatus = pgEnum('WIDGET_STATUS', ['DRAFT', 'PUBLISHED', 'ARCHIVED']);
export const widgetLayoutType = pgEnum('WIDGET_LAYOUT_TYPE', [
'card_grid',
'tag_filter',
'carousel',
'primary_course',
'compact_list',
'editorial_spotlight',
'category_shelf'
]);
export const widgetSelectionMode = pgEnum('WIDGET_SELECTION_MODE', ['manual', 'published']);
export const courseInviteEventType = pgEnum('COURSE_INVITE_EVENT_TYPE', [
'CREATED',
'REVOKED',
@@ -150,7 +162,10 @@ export const analyticsLoginEvents = pgTable(
.primaryKey()
.notNull(),
userId: uuid('user_id').notNull(),
loggedInAt: timestamp('logged_in_at', { withTimezone: true, mode: 'string' }).defaultNow()
loggedInAt: timestamp('logged_in_at', { withTimezone: true, mode: 'string' }).defaultNow(),
loggedInDate: date('logged_in_date')
.default(sql`CURRENT_DATE`)
.notNull()
},
(table) => [
index('idx_analytics_login_events_logged_in_at').using(
@@ -158,12 +173,16 @@ export const analyticsLoginEvents = pgTable(
table.loggedInAt.asc().nullsLast().op('timestamptz_ops')
),
index('idx_analytics_login_events_user_id').using('btree', table.userId.asc().nullsLast().op('uuid_ops')),
index('idx_analytics_login_events_logged_in_date').using(
'btree',
table.loggedInDate.asc().nullsLast().op('date_ops')
),
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: 'analytics_login_events_user_id_fkey'
}),
unique('analytics_login_events_user_id_unique').on(table.userId)
unique('analytics_login_events_user_id_date_unique').on(table.userId, table.loggedInDate)
]
);
@@ -596,6 +615,15 @@ export const course = pgTable(
exerciseMinScorePercent?: number | null;
emailMessage?: string | null;
}>(),
compliance: jsonb().$type<{
retakeIntervalMonths: number;
gracePeriodDays?: number;
reminderDaysBefore?: number[];
isMandatory?: boolean;
framework?: 'HIPAA' | 'OSHA' | 'SOX' | 'GDPR' | 'PCI_DSS' | 'FERPA' | 'ISO' | 'CUSTOM' | null;
maxRetakeAttempts?: number | null;
passingScore?: number;
}>(),
status: text().default('ACTIVE').notNull(),
type: courseType().default('LIVE_CLASS')
},
@@ -610,6 +638,127 @@ export const course = pgTable(
]
);
export const courseCompletionRecord = pgTable(
'course_completion_record',
{
id: uuid()
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
courseId: uuid('course_id').notNull(),
groupMemberId: uuid('group_member_id').notNull(),
profileId: uuid('profile_id').notNull(),
cycleNumber: integer('cycle_number').default(1).notNull(),
status: varchar().default('not_started').notNull(),
dueDate: timestamp('due_date', { withTimezone: true, mode: 'string' }).notNull(),
startedAt: timestamp('started_at', { withTimezone: true, mode: 'string' }),
completedAt: timestamp('completed_at', { withTimezone: true, mode: 'string' }),
validUntil: timestamp('valid_until', { withTimezone: true, mode: 'string' }),
expiredAt: timestamp('expired_at', { withTimezone: true, mode: 'string' }),
score: integer(),
attempts: integer().default(0),
timeSpentMinutes: integer('time_spent_minutes').default(0),
waivedBy: uuid('waived_by'),
waiverReason: text('waiver_reason'),
waiverExpiresAt: timestamp('waiver_expires_at', { withTimezone: true, mode: 'string' }),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
},
(table) => [
foreignKey({
columns: [table.courseId],
foreignColumns: [course.id],
name: 'course_completion_record_course_id_fkey'
}).onDelete('cascade'),
foreignKey({
columns: [table.groupMemberId],
foreignColumns: [groupmember.id],
name: 'course_completion_record_group_member_id_fkey'
}).onDelete('cascade'),
foreignKey({
columns: [table.profileId],
foreignColumns: [profile.id],
name: 'course_completion_record_profile_id_fkey'
}).onDelete('cascade'),
foreignKey({
columns: [table.waivedBy],
foreignColumns: [profile.id],
name: 'course_completion_record_waived_by_fkey'
}).onDelete('set null'),
unique('course_completion_record_course_profile_cycle_key').on(table.courseId, table.profileId, table.cycleNumber),
index('idx_course_completion_record_course_id').on(table.courseId),
index('idx_course_completion_record_profile_id').on(table.profileId),
index('idx_course_completion_record_status').on(table.status),
index('idx_course_completion_record_due_date').on(table.dueDate),
index('idx_course_completion_record_valid_until').on(table.validUntil)
]
);
export const courseCompletionNotificationEvent = pgTable(
'course_completion_notification_event',
{
id: uuid()
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
courseCompletionRecordId: uuid('course_completion_record_id').notNull(),
channel: varchar().notNull(),
eventType: varchar('event_type').notNull(),
sentAt: timestamp('sent_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
},
(table) => [
foreignKey({
columns: [table.courseCompletionRecordId],
foreignColumns: [courseCompletionRecord.id],
name: 'course_completion_notification_event_record_id_fkey'
}).onDelete('cascade'),
unique('course_completion_notification_event_record_channel_type_key').on(
table.courseCompletionRecordId,
table.channel,
table.eventType
)
]
);
export const courseCertificateIssue = pgTable(
'course_certificate_issue',
{
id: uuid()
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
courseId: uuid('course_id').notNull(),
profileId: uuid('profile_id').notNull(),
courseCompletionRecordId: uuid('course_completion_record_id').notNull(),
cycleNumber: integer('cycle_number').notNull(),
issuedAt: timestamp('issued_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
expiresAt: timestamp('expires_at', { withTimezone: true, mode: 'string' }).notNull(),
status: varchar().default('valid').notNull(),
fileUrl: text('file_url')
},
(table) => [
foreignKey({
columns: [table.courseId],
foreignColumns: [course.id],
name: 'course_certificate_issue_course_id_fkey'
}).onDelete('cascade'),
foreignKey({
columns: [table.profileId],
foreignColumns: [profile.id],
name: 'course_certificate_issue_profile_id_fkey'
}).onDelete('cascade'),
foreignKey({
columns: [table.courseCompletionRecordId],
foreignColumns: [courseCompletionRecord.id],
name: 'course_certificate_issue_record_id_fkey'
}).onDelete('cascade'),
unique('course_certificate_issue_record_id_key').on(table.courseCompletionRecordId),
index('idx_course_certificate_issue_course_id').on(table.courseId),
index('idx_course_certificate_issue_profile_id').on(table.profileId),
index('idx_course_certificate_issue_status').on(table.status)
]
);
export const appsPoll = pgTable(
'apps_poll',
{
@@ -1859,6 +2008,132 @@ export const tagAssignment = pgTable(
]
);
export const widget = pgTable(
'widget',
{
id: uuid()
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
organizationId: uuid('organization_id').notNull(),
name: varchar({ length: 120 }).notNull(),
status: widgetStatus('status').default('DRAFT').notNull(),
layoutType: widgetLayoutType('layout_type').default('card_grid').notNull(),
selectionMode: widgetSelectionMode('selection_mode').default('manual').notNull(),
publicKey: varchar('public_key', { length: 64 }).notNull(),
config: jsonb('config')
.default(sql`'{}'::jsonb`)
.notNull(),
hasUnpublishedChanges: boolean('has_unpublished_changes').default(false).notNull(),
latestPublishedVersionId: uuid('latest_published_version_id'),
createdByUserId: uuid('created_by_user_id').notNull(),
updatedByUserId: uuid('updated_by_user_id'),
deletedAt: timestamp('deleted_at', { withTimezone: true, mode: 'string' }),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' })
.default(sql`timezone('utc'::text, now())`)
.notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' })
.default(sql`timezone('utc'::text, now())`)
.notNull()
},
(table) => [
foreignKey({
columns: [table.organizationId],
foreignColumns: [organization.id],
name: 'widget_organization_id_fkey'
}).onDelete('cascade'),
foreignKey({
columns: [table.createdByUserId],
foreignColumns: [user.id],
name: 'widget_created_by_user_id_fkey'
}).onDelete('restrict'),
foreignKey({
columns: [table.updatedByUserId],
foreignColumns: [user.id],
name: 'widget_updated_by_user_id_fkey'
}).onDelete('set null'),
index('idx_widget_organization_id').on(table.organizationId),
index('idx_widget_status').on(table.status),
unique('widget_public_key_key').on(table.publicKey)
]
);
export const widgetCourse = pgTable(
'widget_course',
{
id: uuid()
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
widgetId: uuid('widget_id').notNull(),
courseId: uuid('course_id').notNull(),
order: integer().default(0).notNull(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' })
.default(sql`timezone('utc'::text, now())`)
.notNull()
},
(table) => [
foreignKey({
columns: [table.widgetId],
foreignColumns: [widget.id],
name: 'widget_course_widget_id_fkey'
}).onDelete('cascade'),
foreignKey({
columns: [table.courseId],
foreignColumns: [course.id],
name: 'widget_course_course_id_fkey'
}).onDelete('cascade'),
index('idx_widget_course_widget_id').on(table.widgetId),
index('idx_widget_course_course_id').on(table.courseId),
unique('widget_course_widget_course_key').on(table.widgetId, table.courseId)
]
);
export const widgetVersion = pgTable(
'widget_version',
{
id: uuid()
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
widgetId: uuid('widget_id').notNull(),
version: integer().notNull(),
configSnapshot: jsonb('config_snapshot')
.default(sql`'{}'::jsonb`)
.notNull(),
payloadSnapshot: jsonb('payload_snapshot')
.default(sql`'{}'::jsonb`)
.notNull(),
runtimeManifest: jsonb('runtime_manifest')
.default(sql`'{}'::jsonb`)
.notNull(),
rolledBackFromVersionId: uuid('rolled_back_from_version_id'),
publishedByUserId: uuid('published_by_user_id').notNull(),
publishedAt: timestamp('published_at', { withTimezone: true, mode: 'string' })
.default(sql`timezone('utc'::text, now())`)
.notNull()
},
(table) => [
foreignKey({
columns: [table.widgetId],
foreignColumns: [widget.id],
name: 'widget_version_widget_id_fkey'
}).onDelete('cascade'),
foreignKey({
columns: [table.publishedByUserId],
foreignColumns: [user.id],
name: 'widget_version_published_by_user_id_fkey'
}).onDelete('restrict'),
foreignKey({
columns: [table.rolledBackFromVersionId],
foreignColumns: [table.id],
name: 'widget_version_rolled_back_from_version_id_fkey'
}).onDelete('set null'),
index('idx_widget_version_widget_id').on(table.widgetId),
unique('widget_version_widget_version_key').on(table.widgetId, table.version)
]
);
export const dashOrgStats = pgView('dash_org_stats', {
orgId: uuid('org_id'),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
@@ -2273,3 +2548,107 @@ export const programNewsfeedComment = pgTable(
}).onDelete('cascade')
]
);
// ─── AI Token Usage ──────────────────────────────────────────────────────────
export const aiTokenUsage = pgTable(
'ai_token_usage',
{
id: bigint({ mode: 'number' }).primaryKey().generatedByDefaultAsIdentity({
name: 'ai_token_usage_id_seq',
startWith: 1,
increment: 1,
minValue: 1,
cache: 1
}),
orgId: uuid('org_id').notNull(),
userId: uuid('user_id').notNull(),
courseId: uuid('course_id').notNull(),
promptTokens: integer('prompt_tokens').notNull(),
completionTokens: integer('completion_tokens').notNull(),
model: text().notNull(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
},
(table) => [
foreignKey({
columns: [table.orgId],
foreignColumns: [organization.id],
name: 'ai_token_usage_org_id_fkey'
}),
index('idx_ai_token_usage_org_month').on(table.orgId, table.createdAt)
]
);
// ─── AI Credit Balance ───────────────────────────────────────────────────────
export const aiCreditBalance = pgTable(
'ai_credit_balance',
{
id: bigint({ mode: 'number' }).primaryKey().generatedByDefaultAsIdentity({
name: 'ai_credit_balance_id_seq',
startWith: 1,
increment: 1,
minValue: 1,
cache: 1
}),
orgId: uuid('org_id').notNull(),
balance: integer().notNull().default(0),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
},
(table) => [
foreignKey({
columns: [table.orgId],
foreignColumns: [organization.id],
name: 'ai_credit_balance_org_id_fkey'
}),
unique('ai_credit_balance_org_id_key').on(table.orgId)
]
);
export const aiChatConversation = pgTable(
'ai_chat_conversation',
{
id: uuid()
.default(sql`gen_random_uuid()`)
.primaryKey(),
courseId: uuid('course_id').notNull(),
userId: uuid('user_id').notNull(),
title: text().default('New conversation'),
messages: jsonb().notNull().default([]),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
},
(table) => [
foreignKey({
columns: [table.courseId],
foreignColumns: [course.id],
name: 'ai_chat_conversation_course_id_fkey'
}),
index('idx_ai_chat_conversation_course_user').on(table.courseId, table.userId)
]
);
export const aiChatDocument = pgTable(
'ai_chat_document',
{
id: text().primaryKey(),
conversationId: uuid('conversation_id').notNull(),
courseId: uuid('course_id').notNull(),
userId: uuid('user_id').notNull(),
assetId: uuid('asset_id'),
fileName: text('file_name').notNull(),
mimeType: text('mime_type').notNull(),
text: text().notNull(),
wordCount: integer('word_count').notNull().default(0),
pageCount: integer('page_count'),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
},
(table) => [
foreignKey({
columns: [table.conversationId],
foreignColumns: [aiChatConversation.id],
name: 'ai_chat_document_conversation_id_fkey'
}).onDelete('cascade'),
index('idx_ai_chat_document_conversation').on(table.conversationId)
]
);
+30 -7
View File
@@ -10,7 +10,7 @@ import { seedGroupmembers } from '@db/utils/seed/groupmember';
import { type LessonTemplate, seedLessons } from '@db/utils/seed/lesson';
import { seedOrganization } from '@db/utils/seed/organization';
import { seedOrganizationMember } from '@db/utils/seed/organizationmember';
import { seedEnterpriseOrganizationPlan } from '@db/utils/seed/organizationPlan';
import { seedEarlyAdopterOrganizationPlan, seedEnterpriseOrganizationPlan } from '@db/utils/seed/organizationPlan';
import { seedProfile } from '@db/utils/seed/profile';
import { seedQuestionTypes } from '@db/utils/seed/questionType';
import { seedQuestions } from '@db/utils/seed/question';
@@ -32,6 +32,10 @@ const STUDENT_USER_ID = '0c256e75-aa40-4f62-8d30-0217ca1c60d9';
const ENTERPRISE_ORG_ID = '2b8f4a1c-6d3e-4b2a-9f7e-1c4d8a6e9b0f';
const ENTERPRISE_ADMIN_USER_ID = '3c9052de-7e4f-4c3a-8d8e-2d5e9b7f1a3c';
const ENTERPRISE_STUDENT_USER_ID = '4da163ef-8050-4d4b-9c9f-3e6fac802b4d';
const EARLY_ADOPTER_ORG_ID = '5c7d4b2e-8f5a-4c3b-ae8f-2d6e9c1b0f5a';
const EARLY_ADOPTER_ADMIN_USER_ID = '6e0163ff-9161-4d4b-be9f-3e7fbd913c5e';
const EARLY_ADOPTER_STUDENT_USER_ID = '7fb274aa-a272-4e5c-cf0a-4f8cce024d6f';
const EARLY_ADOPTER_GROUP_ID = '8ac385bb-b383-4f6d-d01b-5a9ddf135e7a';
// Parse command line arguments
function parseFlags(): Set<string> {
@@ -66,7 +70,7 @@ Flags:
--accounts Seed accounts
--organizations Seed organizations
--organization-members Seed organization members
--organization-plan Seed enterprise plan for Coursera Test org
--organization-plan Seed enterprise + early adopter plans
--groups Seed groups
--group-members Seed group members
--courses Seed courses
@@ -113,7 +117,11 @@ const seedFunctions = {
},
organizations: async () => {
console.log('📝 Seeding organizations...');
await seedOrganization({ testOrgId: TEST_ORG_ID, enterpriseOrgId: ENTERPRISE_ORG_ID });
await seedOrganization({
testOrgId: TEST_ORG_ID,
enterpriseOrgId: ENTERPRISE_ORG_ID,
earlyAdopterOrgId: EARLY_ADOPTER_ORG_ID
});
},
'organization-members': async () => {
console.log('📝 Seeding organization members...');
@@ -123,12 +131,17 @@ const seedFunctions = {
studentUserId: STUDENT_USER_ID,
enterpriseOrgId: ENTERPRISE_ORG_ID,
enterpriseAdminUserId: ENTERPRISE_ADMIN_USER_ID,
enterpriseStudentUserId: ENTERPRISE_STUDENT_USER_ID
enterpriseStudentUserId: ENTERPRISE_STUDENT_USER_ID,
earlyAdopterOrgId: EARLY_ADOPTER_ORG_ID,
earlyAdopterAdminUserId: EARLY_ADOPTER_ADMIN_USER_ID,
earlyAdopterStudentUserId: EARLY_ADOPTER_STUDENT_USER_ID
});
},
'organization-plan': async () => {
console.log('📝 Seeding enterprise organization plan...');
await seedEnterpriseOrganizationPlan({ enterpriseOrgId: ENTERPRISE_ORG_ID });
console.log('📝 Seeding early adopter organization plan...');
await seedEarlyAdopterOrganizationPlan({ earlyAdopterOrgId: EARLY_ADOPTER_ORG_ID });
},
groups: async () => {
console.log('📝 Seeding groups...');
@@ -136,7 +149,9 @@ const seedFunctions = {
mvcGroupId: MVC_GROUP_ID,
reactGroupId: REACT_GROUP_ID,
pandasGroupId: PANDAS_GROUP_ID,
testOrgId: TEST_ORG_ID
testOrgId: TEST_ORG_ID,
earlyAdopterGroupId: EARLY_ADOPTER_GROUP_ID,
earlyAdopterOrgId: EARLY_ADOPTER_ORG_ID
});
},
'group-members': async () => {
@@ -146,12 +161,20 @@ const seedFunctions = {
reactGroupId: REACT_GROUP_ID,
pandasGroupId: PANDAS_GROUP_ID,
adminUserId: ADMIN_USER_ID,
studentUserId: STUDENT_USER_ID
studentUserId: STUDENT_USER_ID,
earlyAdopterGroupId: EARLY_ADOPTER_GROUP_ID,
earlyAdopterAdminUserId: EARLY_ADOPTER_ADMIN_USER_ID,
earlyAdopterStudentUserId: EARLY_ADOPTER_STUDENT_USER_ID
});
},
courses: async () => {
console.log('📝 Seeding courses...');
await seedCourses({ mvcGroupId: MVC_GROUP_ID, reactGroupId: REACT_GROUP_ID, pandasGroupId: PANDAS_GROUP_ID });
await seedCourses({
mvcGroupId: MVC_GROUP_ID,
reactGroupId: REACT_GROUP_ID,
pandasGroupId: PANDAS_GROUP_ID,
earlyAdopterGroupId: EARLY_ADOPTER_GROUP_ID
});
},
sections: async () => {
console.log('📝 Seeding course sections...');
+15
View File
@@ -60,6 +60,12 @@ export type TNewSubmissionstatus = typeof schema.submissionstatus.$inferInsert;
export type TCourse = typeof schema.course.$inferSelect;
export type TNewCourse = typeof schema.course.$inferInsert;
export type TCourseCompletionRecord = typeof schema.courseCompletionRecord.$inferSelect;
export type TNewCourseCompletionRecord = typeof schema.courseCompletionRecord.$inferInsert;
export type TCourseCompletionNotificationEvent = typeof schema.courseCompletionNotificationEvent.$inferSelect;
export type TNewCourseCompletionNotificationEvent = typeof schema.courseCompletionNotificationEvent.$inferInsert;
export type TCourseCertificateIssue = typeof schema.courseCertificateIssue.$inferSelect;
export type TNewCourseCertificateIssue = typeof schema.courseCertificateIssue.$inferInsert;
export type TAppsPoll = typeof schema.appsPoll.$inferSelect;
export type TNewAppsPoll = typeof schema.appsPoll.$inferInsert;
@@ -156,6 +162,12 @@ export type TNewTag = typeof schema.tag.$inferInsert;
export type TTagAssignment = typeof schema.tagAssignment.$inferSelect;
export type TNewTagAssignment = typeof schema.tagAssignment.$inferInsert;
export type TWidget = typeof schema.widget.$inferSelect;
export type TNewWidget = typeof schema.widget.$inferInsert;
export type TWidgetCourse = typeof schema.widgetCourse.$inferSelect;
export type TNewWidgetCourse = typeof schema.widgetCourse.$inferInsert;
export type TWidgetVersion = typeof schema.widgetVersion.$inferSelect;
export type TNewWidgetVersion = typeof schema.widgetVersion.$inferInsert;
export type TExerciseTemplate = typeof schema.exerciseTemplate.$inferSelect;
export type TNewExerciseTemplate = typeof schema.exerciseTemplate.$inferInsert;
@@ -195,3 +207,6 @@ export type TAutomationUsageCategory = (typeof schema.automationUsageCategory.en
export type TCourseImportSourceType = (typeof schema.courseImportSourceType.enumValues)[number];
export type TCourseImportDraftStatus = (typeof schema.courseImportDraftStatus.enumValues)[number];
export type TWidgetStatus = (typeof schema.widgetStatus.enumValues)[number];
export type TWidgetLayoutType = (typeof schema.widgetLayoutType.enumValues)[number];
export type TWidgetSelectionMode = (typeof schema.widgetSelectionMode.enumValues)[number];
+30 -1
View File
@@ -6,15 +6,17 @@ interface SeedCourse {
mvcGroupId: string;
reactGroupId: string;
pandasGroupId: string;
earlyAdopterGroupId: string;
}
export async function seedCourses({ mvcGroupId, reactGroupId, pandasGroupId }: SeedCourse) {
export async function seedCourses({ mvcGroupId, reactGroupId, pandasGroupId, earlyAdopterGroupId }: SeedCourse) {
const existingCourses = await db.select().from(course);
const existingCourseIds = existingCourses.map((c) => c.id);
const mvcCourseId = '98e6e798-f0bd-4f9d-a6f5-ce0816a4f97e';
const reactCourseId = '16e3bc8d-5d1b-4708-988e-93abae288ccf';
const pandasCourseId = 'f0a85d18-aff4-412f-b8e6-3b34ef098dce';
const earlyAdopterCourseId = '9bd496cc-c494-4a7e-e12c-6baeef246f8b';
const coursesToInsert: TNewCourse[] = [
{
@@ -98,6 +100,33 @@ export async function seedCourses({ mvcGroupId, reactGroupId, pandasGroupId }: S
theme: 'professional'
},
status: 'ACTIVE'
},
{
id: earlyAdopterCourseId,
title: 'Product Management Fundamentals',
description:
'A practical introduction to product management covering roadmaps, prioritization frameworks, user research, and cross-functional collaboration skills needed to ship great products.',
overview: 'Welcome to Product Management Fundamentals 🚀',
groupId: earlyAdopterGroupId,
isTemplate: false,
logo: '',
slug: 'product-management-fundamentals',
metadata: {
goals: '',
grading: false,
description: '',
requirements: '',
lessonDownload: false,
allowNewStudent: true
},
cost: 0,
currency: 'NGN',
isPublished: true,
certificate: {
isDownloadable: false,
theme: 'professional'
},
status: 'ACTIVE'
}
].filter((c) => !existingCourseIds.includes(c.id));
+17 -1
View File
@@ -5,9 +5,18 @@ interface SeedGroup {
reactGroupId: string;
pandasGroupId: string;
testOrgId: string;
earlyAdopterGroupId: string;
earlyAdopterOrgId: string;
}
export async function seedGroup({ mvcGroupId, reactGroupId, pandasGroupId, testOrgId }: SeedGroup) {
export async function seedGroup({
mvcGroupId,
reactGroupId,
pandasGroupId,
testOrgId,
earlyAdopterGroupId,
earlyAdopterOrgId
}: SeedGroup) {
const existingGroups = await db.select().from(group);
const existingGroupIds = existingGroups.map((g) => g.id);
@@ -32,6 +41,13 @@ export async function seedGroup({ mvcGroupId, reactGroupId, pandasGroupId, testO
description:
'Unlock the power of data with our "Data Science with Python and Pandas" course. Dive into Python programming fundamentals and then journey into the world of Pandas for efficient data manipulation and analysis.',
organizationId: testOrgId
},
{
id: earlyAdopterGroupId,
name: 'Product Management Fundamentals',
description:
'A practical introduction to product management covering roadmaps, prioritization frameworks, user research, and cross-functional collaboration skills needed to ship great products.',
organizationId: earlyAdopterOrgId
}
].filter((g) => !existingGroupIds.includes(g.id));
+18 -1
View File
@@ -6,6 +6,9 @@ interface SeedGroupmember {
pandasGroupId: string;
adminUserId: string;
studentUserId: string;
earlyAdopterGroupId: string;
earlyAdopterAdminUserId: string;
earlyAdopterStudentUserId: string;
}
export async function seedGroupmembers({
@@ -13,7 +16,10 @@ export async function seedGroupmembers({
reactGroupId,
pandasGroupId,
adminUserId,
studentUserId
studentUserId,
earlyAdopterGroupId,
earlyAdopterAdminUserId,
earlyAdopterStudentUserId
}: SeedGroupmember) {
const existingGroupMembers = await db.select().from(groupmember);
const existingGroupMemberKeys = existingGroupMembers.map((gm) => `${gm.groupId}-${gm.profileId || gm.email}`);
@@ -41,6 +47,17 @@ export async function seedGroupmembers({
groupId: pandasGroupId,
roleId: 3, // STUDENT
profileId: studentUserId
},
{
groupId: earlyAdopterGroupId,
roleId: 2, // TUTOR
profileId: earlyAdopterAdminUserId,
email: 'early-adopter@test.com'
},
{
groupId: earlyAdopterGroupId,
roleId: 3, // STUDENT
profileId: earlyAdopterStudentUserId
}
].filter((gm) => !existingGroupMemberKeys.includes(`${gm.groupId}-${gm.profileId || gm.email}`));
+22 -1
View File
@@ -1,6 +1,14 @@
import { db, organization } from '@db/drizzle';
export async function seedOrganization({ testOrgId, enterpriseOrgId }: { testOrgId: string; enterpriseOrgId: string }) {
export async function seedOrganization({
testOrgId,
enterpriseOrgId,
earlyAdopterOrgId
}: {
testOrgId: string;
enterpriseOrgId: string;
earlyAdopterOrgId: string;
}) {
const existingOrgs = await db.select().from(organization);
const existingOrgIds = existingOrgs.map((o) => o.id);
@@ -30,6 +38,19 @@ export async function seedOrganization({ testOrgId, enterpriseOrgId }: { testOrg
course: { grading: true, newsfeed: true },
dashboard: { exercise: true, community: true, bannerText: '', bannerImage: '' }
}
},
{
id: earlyAdopterOrgId,
name: 'Skillshare Test',
siteName: 'skillshare-test',
settings: {},
landingpage: {},
theme: '',
customization: {
apps: { poll: true, comments: true },
course: { grading: true, newsfeed: true },
dashboard: { exercise: true, community: true, bannerText: '', bannerImage: '' }
}
}
].filter((o) => !existingOrgIds.includes(o.id));
@@ -26,3 +26,30 @@ export async function seedEnterpriseOrganizationPlan({ enterpriseOrgId }: { ente
});
console.log(' ✓ Inserted enterprise organization plan');
}
export async function seedEarlyAdopterOrganizationPlan({ earlyAdopterOrgId }: { earlyAdopterOrgId: string }) {
const existing = await db
.select()
.from(organizationPlan)
.where(
and(
eq(organizationPlan.orgId, earlyAdopterOrgId),
eq(organizationPlan.planName, 'EARLY_ADOPTER'),
eq(organizationPlan.isActive, true)
)
);
if (existing.length > 0) {
console.log(' ✓ Early adopter organization plan already exists, skipping');
return;
}
await db.insert(organizationPlan).values({
orgId: earlyAdopterOrgId,
planName: 'EARLY_ADOPTER',
isActive: true,
subscriptionId: 'seed-skillshare-test-early-adopter',
provider: 'seed'
});
console.log(' ✓ Inserted early adopter organization plan');
}
@@ -7,6 +7,9 @@ interface SeedOrganizationMember {
enterpriseOrgId: string;
enterpriseAdminUserId: string;
enterpriseStudentUserId: string;
earlyAdopterOrgId: string;
earlyAdopterAdminUserId: string;
earlyAdopterStudentUserId: string;
}
export async function seedOrganizationMember({
@@ -15,7 +18,10 @@ export async function seedOrganizationMember({
studentUserId,
enterpriseOrgId,
enterpriseAdminUserId,
enterpriseStudentUserId
enterpriseStudentUserId,
earlyAdopterOrgId,
earlyAdopterAdminUserId,
earlyAdopterStudentUserId
}: SeedOrganizationMember) {
const existingOrgMembers = await db.select().from(organizationmember);
const existingOrgMemberKeys = existingOrgMembers.map((om) => `${om.organizationId}-${om.profileId}`);
@@ -44,6 +50,18 @@ export async function seedOrganizationMember({
roleId: 3, // STUDENT
profileId: enterpriseStudentUserId,
verified: false
},
{
organizationId: earlyAdopterOrgId,
roleId: 1, // ADMIN
profileId: earlyAdopterAdminUserId,
verified: false
},
{
organizationId: earlyAdopterOrgId,
roleId: 3, // STUDENT
profileId: earlyAdopterStudentUserId,
verified: false
}
].filter((om) => !existingOrgMemberKeys.includes(`${om.organizationId}-${om.profileId}`));