Enhance audience management features with new components and API integrations
- Added `AssignCoursesModal`, `AudienceMemberRow`, `AudienceTable`, and related components for improved audience management UI. - Implemented audience invite actions including resend and revoke functionality. - Updated audience API to support importing members and assigning courses. - Refactored audience data handling to include status and invite management. - Improved overall user experience with pagination and selection features in the audience table.
This commit is contained in:
@@ -66,12 +66,13 @@ export const auth: ReturnType<typeof betterAuth> = betterAuth({
|
||||
user: {
|
||||
create: {
|
||||
after: async (user) => {
|
||||
console.log('[auth] databaseHooks.user.create.after: running', { userId: user.id });
|
||||
await createProfileHook(user);
|
||||
}
|
||||
},
|
||||
update: {
|
||||
after: async (user) => {
|
||||
console.log('update user', user);
|
||||
console.log('[auth] databaseHooks.user.update.after: running', { userId: user.id });
|
||||
await syncUserWithProfile(user);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ async function isSelfHostedFirstSignup(): Promise<boolean> {
|
||||
* Self-hosted first signup: auto-verify email (no orgs exist yet, this user will create the only org).
|
||||
*/
|
||||
export const createProfileHook = async (user: User) => {
|
||||
console.debug('createProfileHook', user);
|
||||
console.log('[auth] createProfileHook: running', { userId: user.id });
|
||||
|
||||
const existingProfile = await db.select().from(schema.profile).where(eq(schema.profile.id, user.id)).limit(1);
|
||||
console.debug('existingProfile', existingProfile);
|
||||
|
||||
@@ -16,6 +16,7 @@ const afterHooks: Array<(context: AuthMiddlewareContext) => Promise<unknown>> =
|
||||
* This function will be called by better-auth after each request
|
||||
*/
|
||||
export const combineAfterHooks = async (context: AuthMiddlewareContext) => {
|
||||
console.log('[auth] combineAfterHooks: running');
|
||||
for (const hook of afterHooks) {
|
||||
try {
|
||||
await hook(context);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import * as schema from '@db/schema';
|
||||
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { and, eq, isNull } from 'drizzle-orm';
|
||||
|
||||
import { User } from 'better-auth';
|
||||
import { db } from '@db/drizzle';
|
||||
import { enrollUsersInCourseGroups } from '@db/queries/group';
|
||||
import { getOrgCourseGroups } from '@db/queries/course';
|
||||
import { markUserAndProfileEmailVerified } from '@db/queries/auth';
|
||||
|
||||
const DEFAULT_STUDENT_ROLE_ID = 3;
|
||||
@@ -21,13 +23,25 @@ export async function ensureOrgMembership(
|
||||
): Promise<void> {
|
||||
const emailLower = email.toLowerCase();
|
||||
|
||||
const [existing] = await db
|
||||
const [existingByProfile] = await db
|
||||
.select()
|
||||
.from(schema.organizationmember)
|
||||
.where(and(eq(schema.organizationmember.organizationId, orgId), eq(schema.organizationmember.profileId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
if (existingByProfile) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** Audience import pre-creates a row with org + email before profile exists; do not insert again. */
|
||||
const [existingByEmail] = await db
|
||||
.select()
|
||||
.from(schema.organizationmember)
|
||||
.where(and(eq(schema.organizationmember.organizationId, orgId), eq(schema.organizationmember.email, emailLower)))
|
||||
.limit(1);
|
||||
|
||||
if (existingByEmail?.profileId && existingByEmail.profileId !== userId) {
|
||||
console.error('ensureOrgMembership: org member email already linked to another profile');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -38,20 +52,33 @@ export async function ensureOrgMembership(
|
||||
and(
|
||||
eq(schema.organizationInvite.organizationId, orgId),
|
||||
eq(schema.organizationInvite.email, emailLower),
|
||||
eq(schema.organizationInvite.isRevoked, false)
|
||||
eq(schema.organizationInvite.isRevoked, false),
|
||||
isNull(schema.organizationInvite.acceptedAt)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (invite) {
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(schema.organizationmember).values({
|
||||
organizationId: orgId,
|
||||
profileId: userId,
|
||||
email: emailLower,
|
||||
roleId: invite.roleId,
|
||||
verified: true
|
||||
});
|
||||
if (existingByEmail) {
|
||||
await tx
|
||||
.update(schema.organizationmember)
|
||||
.set({
|
||||
profileId: userId,
|
||||
email: emailLower,
|
||||
roleId: invite.roleId,
|
||||
verified: true
|
||||
})
|
||||
.where(eq(schema.organizationmember.id, existingByEmail.id));
|
||||
} else {
|
||||
await tx.insert(schema.organizationmember).values({
|
||||
organizationId: orgId,
|
||||
profileId: userId,
|
||||
email: emailLower,
|
||||
roleId: invite.roleId,
|
||||
verified: true
|
||||
});
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(schema.organizationInvite)
|
||||
@@ -65,10 +92,46 @@ export async function ensureOrgMembership(
|
||||
await markUserAndProfileEmailVerified(userId, tx);
|
||||
});
|
||||
|
||||
const metadata = invite.metadata as Record<string, unknown> | null;
|
||||
const courseIds = Array.isArray(metadata?.courseIds) ? (metadata.courseIds as string[]) : [];
|
||||
|
||||
if (courseIds.length > 0) {
|
||||
try {
|
||||
const courseGroups = await getOrgCourseGroups(orgId, courseIds);
|
||||
const validGroupIds = courseGroups.map((cg) => cg.groupId).filter(Boolean) as string[];
|
||||
await enrollUsersInCourseGroups(validGroupIds, [{ profileId: userId, email: emailLower }], invite.roleId);
|
||||
} catch (error) {
|
||||
console.error('ensureOrgMembership course enrollment error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
console.debug('User joined org via invite:', orgId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (existingByEmail) {
|
||||
const [policy] = await db
|
||||
.select()
|
||||
.from(schema.organizationAuthPolicy)
|
||||
.where(eq(schema.organizationAuthPolicy.organizationId, orgId))
|
||||
.limit(1);
|
||||
|
||||
const defaultRoleId = policy?.defaultRoleId ?? DEFAULT_STUDENT_ROLE_ID;
|
||||
const finalRoleId = roleId ?? defaultRoleId;
|
||||
|
||||
await db
|
||||
.update(schema.organizationmember)
|
||||
.set({
|
||||
profileId: userId,
|
||||
verified: true,
|
||||
roleId: finalRoleId
|
||||
})
|
||||
.where(eq(schema.organizationmember.id, existingByEmail.id));
|
||||
|
||||
console.debug('User linked to existing org membership row:', orgId);
|
||||
return;
|
||||
}
|
||||
|
||||
const [policy] = await db
|
||||
.select()
|
||||
.from(schema.organizationAuthPolicy)
|
||||
@@ -94,6 +157,7 @@ export async function ensureOrgMembership(
|
||||
* This runs after profile creation and creates organization membership
|
||||
*/
|
||||
export const ssoProvisioningHook = async (user: User) => {
|
||||
console.log('[auth] ssoProvisioningHook: running', { userId: user.id });
|
||||
if (!user.email) {
|
||||
console.debug('No email for user, skipping SSO provisioning');
|
||||
return;
|
||||
|
||||
@@ -6,7 +6,7 @@ import { eq } from 'drizzle-orm';
|
||||
import { hasVerifiedEmailProvider } from './create-profile';
|
||||
|
||||
export const syncUserWithProfile = async (user: User) => {
|
||||
console.debug('syncUserWithProfile', user);
|
||||
console.log('[auth] syncUserWithProfile: running', { userId: user?.id });
|
||||
|
||||
if (!user?.id) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
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
@@ -50,6 +50,13 @@
|
||||
"when": 1771176910627,
|
||||
"tag": "0006_modern_the_initiative",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1774154467188,
|
||||
"tag": "0007_rainy_vivisector",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as schema from '@db/schema';
|
||||
|
||||
import type { TProfile } from '@db/types';
|
||||
import { db, type DbOrTxClient } from '@db/drizzle';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
|
||||
export const getProfileById = async (id: string) => {
|
||||
const [profile] = await db.select().from(schema.profile).where(eq(schema.profile.id, id)).limit(1);
|
||||
@@ -16,6 +16,12 @@ export const getProfileByEmail = async (email: string) => {
|
||||
return profile;
|
||||
};
|
||||
|
||||
export async function getProfilesByEmails(emails: string[]) {
|
||||
if (emails.length === 0) return [];
|
||||
|
||||
return db.select().from(schema.profile).where(inArray(schema.profile.email, emails));
|
||||
}
|
||||
|
||||
export const updateProfile = async (id: string, data: Partial<Omit<TProfile, 'id' | 'createdAt' | 'updatedAt'>>) => {
|
||||
const [updatedProfile] = await db.update(schema.profile).set(data).where(eq(schema.profile.id, id)).returning();
|
||||
|
||||
|
||||
@@ -879,3 +879,47 @@ export async function deleteCourseSection(sectionId: string, dbClient: DbOrTxCli
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets group IDs for multiple courses at once
|
||||
* @param courseIds Array of course IDs
|
||||
* @returns Array of { courseId, groupId } mappings
|
||||
*/
|
||||
export async function getCourseGroupIds(courseIds: string[]) {
|
||||
try {
|
||||
if (courseIds.length === 0) return [];
|
||||
|
||||
return db
|
||||
.select({ courseId: schema.course.id, groupId: schema.course.groupId })
|
||||
.from(schema.course)
|
||||
.where(inArray(schema.course.id, courseIds));
|
||||
} catch (error) {
|
||||
console.error('getCourseGroupIds error:', error);
|
||||
throw new Error(`Failed to get course group IDs: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets course group mappings for an organization, including course title
|
||||
* @param orgId Organization ID
|
||||
* @param courseIds Array of course IDs to filter
|
||||
* @returns Array of { courseId, courseTitle, groupId }
|
||||
*/
|
||||
export async function getOrgCourseGroups(orgId: string, courseIds: string[]) {
|
||||
try {
|
||||
if (courseIds.length === 0) return [];
|
||||
|
||||
return db
|
||||
.select({
|
||||
courseId: schema.course.id,
|
||||
courseTitle: schema.course.title,
|
||||
groupId: schema.course.groupId
|
||||
})
|
||||
.from(schema.course)
|
||||
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
|
||||
.where(and(eq(schema.group.organizationId, orgId), inArray(schema.course.id, courseIds)));
|
||||
} catch (error) {
|
||||
console.error('getOrgCourseGroups error:', error);
|
||||
throw new Error(`Failed to get org course groups: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as schema from '@db/schema';
|
||||
|
||||
import { TNewGroup, TNewGroupmember } from '@db/types';
|
||||
import { and, eq, isNotNull, or } from 'drizzle-orm';
|
||||
import { and, eq, inArray, isNotNull, or } from 'drizzle-orm';
|
||||
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { db } from '@db/drizzle';
|
||||
@@ -24,6 +24,91 @@ export async function addGroupMember(values: TNewGroupmember) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function addGroupMembers(values: TNewGroupmember[]) {
|
||||
if (values.length === 0) return [];
|
||||
try {
|
||||
return db.insert(schema.groupmember).values(values).returning();
|
||||
} catch (error) {
|
||||
console.error('addGroupMembers error:', error);
|
||||
throw new Error(`Failed to bulk add group members: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getExistingGroupMembers(
|
||||
pairs: Array<{ groupId: string; profileId: string }>
|
||||
): Promise<Set<string>> {
|
||||
if (pairs.length === 0) return new Set();
|
||||
|
||||
try {
|
||||
const groupIds = [...new Set(pairs.map((p) => p.groupId))];
|
||||
const profileIds = [...new Set(pairs.map((p) => p.profileId))];
|
||||
|
||||
const rows = await db
|
||||
.select({ groupId: schema.groupmember.groupId, profileId: schema.groupmember.profileId })
|
||||
.from(schema.groupmember)
|
||||
.where(and(inArray(schema.groupmember.groupId, groupIds), inArray(schema.groupmember.profileId, profileIds)));
|
||||
|
||||
return new Set(rows.map((r) => `${r.groupId}:${r.profileId}`));
|
||||
} catch (error) {
|
||||
console.error('getExistingGroupMembers error:', error);
|
||||
throw new Error(
|
||||
`Failed to check existing group members: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enroll users in course groups, skipping any existing memberships.
|
||||
* Returns the count of newly inserted memberships.
|
||||
*/
|
||||
export async function enrollUsersInCourseGroups(
|
||||
groupIds: string[],
|
||||
users: Array<{ profileId: string; email?: string }>,
|
||||
roleId: number
|
||||
): Promise<number> {
|
||||
if (groupIds.length === 0 || users.length === 0) return 0;
|
||||
|
||||
const pairs = users.flatMap((u) => groupIds.map((groupId) => ({ groupId, profileId: u.profileId })));
|
||||
const existingSet = await getExistingGroupMembers(pairs);
|
||||
const emailByProfile = new Map(users.map((u) => [u.profileId, u.email]));
|
||||
|
||||
const toInsert = pairs
|
||||
.filter((p) => !existingSet.has(`${p.groupId}:${p.profileId}`))
|
||||
.map((p) => ({
|
||||
groupId: p.groupId,
|
||||
roleId,
|
||||
profileId: p.profileId,
|
||||
email: emailByProfile.get(p.profileId) ?? undefined
|
||||
}));
|
||||
|
||||
if (toInsert.length > 0) {
|
||||
await addGroupMembers(toInsert);
|
||||
}
|
||||
|
||||
return toInsert.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a profile is already a member of a specific group
|
||||
* @param groupId Group ID
|
||||
* @param profileId Profile ID
|
||||
* @returns true if the user is already a member
|
||||
*/
|
||||
export async function isGroupMember(groupId: string, profileId: string): Promise<boolean> {
|
||||
try {
|
||||
const [existing] = await db
|
||||
.select({ id: schema.groupmember.id })
|
||||
.from(schema.groupmember)
|
||||
.where(and(eq(schema.groupmember.groupId, groupId), eq(schema.groupmember.profileId, profileId)))
|
||||
.limit(1);
|
||||
|
||||
return !!existing;
|
||||
} catch (error) {
|
||||
console.error('isGroupMember error:', error);
|
||||
throw new Error(`Failed to check group membership: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a user is a member of a course's group (any role)
|
||||
* @param courseId Course ID
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
} from '@db/types';
|
||||
import * as schema from '@db/schema';
|
||||
|
||||
import { and, eq, gt, inArray, isNull, sql } from 'drizzle-orm';
|
||||
import { and, desc, eq, gt, inArray, isNull, sql } from 'drizzle-orm';
|
||||
import { db } from '@db/drizzle';
|
||||
|
||||
export async function createOrganizationInvite(values: TNewOrganizationInvite): Promise<TOrganizationInvite> {
|
||||
@@ -26,6 +26,21 @@ export async function createOrganizationInvite(values: TNewOrganizationInvite):
|
||||
}
|
||||
}
|
||||
|
||||
export async function createOrganizationInvites(values: TNewOrganizationInvite[]): Promise<TOrganizationInvite[]> {
|
||||
if (values.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
return await db.insert(schema.organizationInvite).values(values).returning();
|
||||
} catch (error) {
|
||||
console.error('createOrganizationInvites error:', error);
|
||||
throw new Error(
|
||||
`Failed to create organization invites: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createOrganizationInviteAudit(
|
||||
values: TNewOrganizationInviteAudit
|
||||
): Promise<TOrganizationInviteAudit> {
|
||||
@@ -45,6 +60,23 @@ export async function createOrganizationInviteAudit(
|
||||
}
|
||||
}
|
||||
|
||||
export async function createOrganizationInviteAudits(
|
||||
values: TNewOrganizationInviteAudit[]
|
||||
): Promise<TOrganizationInviteAudit[]> {
|
||||
if (values.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
return await db.insert(schema.organizationInviteAudit).values(values).returning();
|
||||
} catch (error) {
|
||||
console.error('createOrganizationInviteAudits error:', error);
|
||||
throw new Error(
|
||||
`Failed to create organization invite audit events: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function revokeActiveOrganizationInvitesByEmails(
|
||||
organizationId: string,
|
||||
emails: string[],
|
||||
@@ -180,3 +212,96 @@ export async function hasActiveOrganizationInviteForEmail(organizationId: string
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export type TLatestOrgInviteByEmail = {
|
||||
email: string;
|
||||
acceptedAt: string | null;
|
||||
isRevoked: boolean;
|
||||
expiresAt: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the latest invite per email (by created_at) for the given org and email list.
|
||||
*/
|
||||
export async function getLatestOrgInvitesByEmails(
|
||||
organizationId: string,
|
||||
emails: string[]
|
||||
): Promise<TLatestOrgInviteByEmail[]> {
|
||||
if (emails.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const normalized = [...new Set(emails.map((e) => e.toLowerCase().trim()))].filter(Boolean);
|
||||
if (normalized.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await db
|
||||
.select({
|
||||
email: schema.organizationInvite.email,
|
||||
acceptedAt: schema.organizationInvite.acceptedAt,
|
||||
isRevoked: schema.organizationInvite.isRevoked,
|
||||
expiresAt: schema.organizationInvite.expiresAt,
|
||||
createdAt: schema.organizationInvite.createdAt
|
||||
})
|
||||
.from(schema.organizationInvite)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.organizationInvite.organizationId, organizationId),
|
||||
inArray(schema.organizationInvite.email, normalized)
|
||||
)
|
||||
);
|
||||
|
||||
const latestByEmail = new Map<string, TLatestOrgInviteByEmail>();
|
||||
for (const row of rows) {
|
||||
const key = row.email.toLowerCase();
|
||||
const existing = latestByEmail.get(key);
|
||||
if (!existing || new Date(row.createdAt) > new Date(existing.createdAt)) {
|
||||
latestByEmail.set(key, row);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(latestByEmail.values());
|
||||
} catch (error) {
|
||||
console.error('getLatestOrgInvitesByEmails error:', error);
|
||||
throw new Error(
|
||||
`Failed to get latest organization invites by emails: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Latest organization_invite row for an org + email (by created_at), for metadata when resending.
|
||||
*/
|
||||
export async function getLatestOrganizationInviteRowByOrgAndEmail(
|
||||
organizationId: string,
|
||||
email: string
|
||||
): Promise<TOrganizationInvite | null> {
|
||||
const normalized = email.toLowerCase().trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(schema.organizationInvite)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.organizationInvite.organizationId, organizationId),
|
||||
eq(schema.organizationInvite.email, normalized)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(schema.organizationInvite.createdAt))
|
||||
.limit(1);
|
||||
|
||||
return row ?? null;
|
||||
} catch (error) {
|
||||
console.error('getLatestOrganizationInviteRowByOrgAndEmail error:', error);
|
||||
throw new Error(
|
||||
`Failed to get latest organization invite: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ export const createOrganizationMember = async (data: TNewOrganizationmember, dbC
|
||||
* @returns Array of created members
|
||||
*/
|
||||
export const createOrganizationMembers = async (data: TNewOrganizationmember[]) => {
|
||||
const members = await db.insert(schema.organizationmember).values(data).returning();
|
||||
const members = await db.insert(schema.organizationmember).values(data).onConflictDoNothing().returning();
|
||||
|
||||
return members;
|
||||
};
|
||||
@@ -345,34 +345,93 @@ export const getOrganizationTeam = async (orgId: string) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets organization audience (all organization members with student role)
|
||||
* @param orgId Organization ID
|
||||
* @returns Array of student profiles
|
||||
* Gets organization audience (all organization members with student role).
|
||||
* Includes invited members without a profile (LEFT JOIN profile).
|
||||
* Row id is organizationmember.id; use profileId for profile-backed actions when present.
|
||||
*/
|
||||
export const getOrganizationAudience = async (orgId: string) => {
|
||||
const result = await db
|
||||
.select({
|
||||
id: schema.profile.id,
|
||||
memberId: schema.organizationmember.id,
|
||||
profileId: schema.profile.id,
|
||||
fullname: schema.profile.fullname,
|
||||
email: schema.profile.email,
|
||||
email: sql<string>`COALESCE(${schema.profile.email}, ${schema.organizationmember.email})`.as('email'),
|
||||
avatarUrl: schema.profile.avatarUrl,
|
||||
createdAt: schema.profile.createdAt
|
||||
profileCreatedAt: schema.profile.createdAt,
|
||||
memberCreatedAt: schema.organizationmember.createdAt
|
||||
})
|
||||
.from(schema.organizationmember)
|
||||
.innerJoin(schema.profile, eq(schema.organizationmember.profileId, schema.profile.id))
|
||||
.leftJoin(schema.profile, eq(schema.organizationmember.profileId, schema.profile.id))
|
||||
.where(
|
||||
and(eq(schema.organizationmember.organizationId, orgId), eq(schema.organizationmember.roleId, ROLE.STUDENT))
|
||||
);
|
||||
|
||||
return result.map((profile) => ({
|
||||
id: profile.id,
|
||||
name: profile.fullname,
|
||||
email: profile.email || '',
|
||||
avatarUrl: profile.avatarUrl || '',
|
||||
createdAt: profile.createdAt ? new Date(profile.createdAt).toDateString() : ''
|
||||
}));
|
||||
return result.map((row) => {
|
||||
const email = row.email?.trim() ?? '';
|
||||
const name = row.fullname?.trim() || (email.includes('@') ? email.split('@')[0] : email) || '';
|
||||
const createdAtRaw = row.profileId ? row.profileCreatedAt : row.memberCreatedAt;
|
||||
const createdAt = createdAtRaw ? new Date(createdAtRaw).toDateString() : '';
|
||||
|
||||
return {
|
||||
id: row.memberId,
|
||||
profileId: row.profileId ?? null,
|
||||
name,
|
||||
email,
|
||||
avatarUrl: row.avatarUrl || '',
|
||||
createdAt
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Student org member matched by profile email or organizationmember.email (for audience invite actions).
|
||||
*/
|
||||
export async function getStudentOrganizationMemberByOrgAndEmail(
|
||||
orgId: string,
|
||||
email: string
|
||||
): Promise<{ id: number; profileId: string | null; email: string } | null> {
|
||||
const normalized = email.toLowerCase().trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: schema.organizationmember.id,
|
||||
profileId: schema.organizationmember.profileId,
|
||||
displayEmail: sql<string>`COALESCE(${schema.profile.email}, ${schema.organizationmember.email})`.as(
|
||||
'displayEmail'
|
||||
)
|
||||
})
|
||||
.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(eq(schema.organizationmember.email, normalized), eq(schema.profile.email, normalized))
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
profileId: row.profileId,
|
||||
email: (row.displayEmail ?? normalized).trim()
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('getStudentOrganizationMemberByOrgAndEmail error:', error);
|
||||
throw new Error(
|
||||
`Failed to get organization student member: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets organizations with optional filters
|
||||
* @param filters Filter options (siteName, customDomain, isCustomDomainVerified)
|
||||
@@ -687,3 +746,33 @@ export const getActiveOrganizationPlan = async (orgId: string) => {
|
||||
|
||||
return plan ?? null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets organization members by profile IDs
|
||||
* @param orgId Organization ID
|
||||
* @param profileIds Array of profile IDs to look up
|
||||
* @returns Array of { profileId, email } for matching members
|
||||
*/
|
||||
export async function getOrgMembersByProfileIds(orgId: string, profileIds: string[]) {
|
||||
try {
|
||||
if (profileIds.length === 0) return [];
|
||||
|
||||
return db
|
||||
.select({
|
||||
profileId: schema.organizationmember.profileId,
|
||||
email: schema.organizationmember.email
|
||||
})
|
||||
.from(schema.organizationmember)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.organizationmember.organizationId, orgId),
|
||||
inArray(schema.organizationmember.profileId, profileIds)
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('getOrgMembersByProfileIds error:', error);
|
||||
throw new Error(
|
||||
`Failed to get org members by profile IDs: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1475,7 +1475,10 @@ export const organizationmember = pgTable(
|
||||
index('idx_organizationmember_profile_org').on(table.profileId, table.organizationId),
|
||||
uniqueIndex('organizationmember_org_profile_unique')
|
||||
.on(table.organizationId, table.profileId)
|
||||
.where(sql`${table.profileId} IS NOT NULL`)
|
||||
.where(sql`${table.profileId} IS NOT NULL`),
|
||||
uniqueIndex('organizationmember_org_email_unique')
|
||||
.on(table.organizationId, table.email)
|
||||
.where(sql`${table.email} IS NOT NULL`)
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user