feat: migrate auth from supabase to better auth (#596)

* feat: add README for @cio/db package and remove existingAccounts.json

* load session, load profile and organization, add create profile hook

* chore: cache session request

The trick was passing all the cookies to the backend. Previously I was only passing the better-auth.session_token and not better-auth.session_data. Read about the difference here https://www.better-auth.com/docs/concepts/cookies#custom-cookies

* feat: login, logout, onboarding, verify email, reset password.

* feat: complete change email and email verification

* feat: standardise how we make rpc calls and create org endpoints

* fix ui errors

* fix: handle potential undefined customerId and standardize avatar URL property

* refactor: standardize custom domain handling and update telegram chat ID references

* chore: add tailwind prefix to @cio/ui

this fixed the media query issues because  @cio/ui classes were overriding this dashboard tailwind build

* chore: clean up org sidebar

* chore: persist mode state

* feat: implement order breadcrumbs

* chore: upgrade to better auth 1.4

* chore: address common auth flow bugs and setup app for others to setup locally.
This commit is contained in:
Rotimi Best
2025-11-24 01:00:50 +01:00
committed by GitHub
parent 24741644d6
commit a3924100dd
567 changed files with 22364 additions and 13437 deletions
-67
View File
@@ -1,67 +0,0 @@
import { pgTable, text, timestamp, boolean } from "drizzle-orm/pg-core";
export const user = pgTable("user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").default(false).notNull(),
image: text("image"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
role: text("role"),
banned: boolean("banned").default(false),
banReason: text("ban_reason"),
banExpires: timestamp("ban_expires"),
isAnonymous: boolean("is_anonymous"),
});
export const session = pgTable("session", {
id: text("id").primaryKey(),
expiresAt: timestamp("expires_at").notNull(),
token: text("token").notNull().unique(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
impersonatedBy: text("impersonated_by"),
});
export const account = pgTable("account", {
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
});
export const verification = pgTable("verification", {
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
});
+57 -6
View File
@@ -1,24 +1,75 @@
import * as CONSTANTS from './constants';
import * as schema from '@db/schema';
import { admin, anonymous } from 'better-auth/plugins';
import { sendChangeEmailConfirmation, sendVerificationEmail } from './auth/email-verification';
import { betterAuth } from 'better-auth';
import { betterAuth } from 'better-auth/minimal';
import { createProfileHook } from './auth/hooks/create-profile';
import { db } from '@db/drizzle';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { config as emailAndPassword } from './auth/email-password';
import { syncUserWithProfile } from './auth/hooks/sync-user';
export const auth = betterAuth({
baseURL: CONSTANTS.BASE_URL,
database: drizzleAdapter(db, {
provider: 'pg',
schema
schema,
debugLogs: true
}),
emailVerification: {},
emailAndPassword: {
enabled: true
emailAndPassword: emailAndPassword,
user: {
changeEmail: {
enabled: true,
sendChangeEmailConfirmation
}
},
emailVerification: {
enabled: true,
sendVerificationEmail
},
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
accessType: 'offline',
prompt: 'select_account consent'
}
},
trustedOrigins: CONSTANTS.TRUSTED_ORIGINS,
advanced: {
cookiePrefix: 'classroomio',
crossSubDomainCookies: {
enabled: true
},
database: {
generateId: false
}
},
account: {
storeAccountCookie: true
},
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // 1 day (every 1 day the session expiration is updated)
cookieCache: {
enabled: true,
maxAge: 60 * 60 // 1 hour
}
},
databaseHooks: {
user: {
create: {
after: async (user) => {
await createProfileHook(user);
}
},
update: {
after: async (user) => {
await syncUserWithProfile(user);
}
}
}
},
plugins: [admin(), anonymous()]
+68
View File
@@ -0,0 +1,68 @@
import * as schema from '@db/schema';
import bcrypt from 'bcrypt';
import { db, eq } from '@db/drizzle';
import { type BetterAuthOptions } from 'better-auth';
import { sendEmail } from '@cio/email';
/**
* Configuration for the email and password options
* @see https://www.better-auth.com/docs/authentication/email-password
*/
export const config: BetterAuthOptions['emailAndPassword'] = {
enabled: true,
password: {
hash: async (password) => {
return await bcrypt.hash(password, 10);
},
verify: async ({ hash, password }) => {
return await bcrypt.compare(password, hash);
}
},
sendResetPassword: sendResetPassword,
onPasswordReset: onPasswordReset
};
/**
* Types for the email and password options
*/
type ResetPasswordOptions = Parameters<
NonNullable<NonNullable<BetterAuthOptions['emailAndPassword']>['sendResetPassword']>
>[0];
type OnPasswordResetEmail = Parameters<
NonNullable<NonNullable<BetterAuthOptions['emailAndPassword']>['onPasswordReset']>
>[0];
async function sendResetPassword(options: ResetPasswordOptions) {
const { user, url } = options;
const [profile] = await db.select().from(schema.profile).where(eq(schema.profile.id, user.id)).limit(1);
if (!profile) {
throw new Error('Profile not found');
}
await sendEmail('forgotPassword', {
to: user.email,
fields: {
name: profile.fullname,
email: user.email,
link: url
}
});
}
async function onPasswordReset(options: OnPasswordResetEmail) {
const { user } = options;
const [profile] = await db.select().from(schema.profile).where(eq(schema.profile.id, user.id)).limit(1);
if (!profile) {
throw new Error('Profile not found');
}
await sendEmail('onPasswordReset', {
to: user.email,
fields: {
name: profile.fullname
}
});
}
@@ -0,0 +1,64 @@
import { type BetterAuthOptions } from 'better-auth';
import { sendEmail } from '@cio/email';
type EmailVerificationOptions = Parameters<
NonNullable<NonNullable<BetterAuthOptions['emailVerification']>['sendVerificationEmail']>
>[0];
type ChangeEmailConfirmationOptions = Parameters<
NonNullable<NonNullable<NonNullable<BetterAuthOptions['user']>['changeEmail']>['sendChangeEmailConfirmation']>
>[0];
/**
* Email verification flow.
*
* 1. user says I want to change my email from email@old.com to email@new.com
* 2. we send an email to email@old.com asking user to verify if they really want to change their email to email@new.com `sendChangeEmailConfirmation()`. [better-auth].
* 3. user clicks the link in the email
* 4. we update the email in the database. [better-auth].
* 5. we send a confirmation email to email@new.com `sendVerificationEmail()`. [dashboard].
* 6. user clicks the link in the email
* 7. we mark email@new.com as verified. [better-auth].
*/
/**
* Sends a verification email to the user when they sign up or change their email address.
* @param options - The options for sending the verification email.
* @returns The verification email.
*/
export const sendVerificationEmail = async (options: EmailVerificationOptions) => {
const { user, url } = options;
console.log('\nsendVerificationEmail');
// The purpose of this is to prevent verification email trigger from better-auth because we already have a flow in the UI for triggering this email.
if (!url.includes('trigger=app') && !url.includes('trigger%3Dapp')) {
return;
}
await sendEmail('verifyEmail', {
to: user.email,
fields: {
link: url,
userName: user.name || undefined
}
});
};
/**
* Sends a confirmation email to the user when they change their email address.
* @param options - The options for sending the change email confirmation.
* @returns The confirmation email.
*/
export const sendChangeEmailConfirmation = async (options: ChangeEmailConfirmationOptions) => {
const { user, newEmail, url } = options;
console.log('\nsendChangeEmailConfirmation');
await sendEmail('verifyEmail', {
to: user.email,
fields: {
link: url,
newEmail,
userName: user.name || undefined
}
});
};
@@ -0,0 +1,51 @@
import * as schema from '@db/schema';
import { and, eq } from 'drizzle-orm';
import { User } from 'better-auth';
import { db } from '@db/drizzle';
/**
* This hook runs after user creation (email/password or OAuth signup).
*
* We use the profile table for everything related to the user because we heavily used supabase and supabase didn't allow you add fields to the auth.users table.
*
*/
export const createProfileHook = async (user: User) => {
console.debug('createProfileHook', user);
const existingProfile = await db.select().from(schema.profile).where(eq(schema.profile.id, user.id)).limit(1);
console.debug('existingProfile', existingProfile);
if (existingProfile.length) {
return;
}
const googleAccount = await db
.select()
.from(schema.account)
.where(and(eq(schema.account.userId, user.id), eq(schema.account.providerId, 'google')))
.limit(1);
const isEmailVerified = user.emailVerified || googleAccount.length > 0;
const verifiedAt = isEmailVerified ? new Date().toISOString() : undefined;
// Extract username from email (part before @)
const emailMatch = user.email?.match(/^([^@]+)@/);
const emailPrefix = emailMatch ? emailMatch[1] : 'user';
const username = `${emailPrefix}${new Date().getTime()}`;
const fullname = emailPrefix;
try {
const newProfile = await db.insert(schema.profile).values({
id: user.id,
username,
fullname,
email: user.email || undefined,
isEmailVerified,
verifiedAt
});
console.debug('newProfile', newProfile);
} catch (error) {
console.error('Error creating profile for user:', error);
}
};
+26
View File
@@ -0,0 +1,26 @@
import { createAuthMiddleware } from 'better-auth/api';
export type AuthMiddlewareContext = Parameters<Parameters<typeof createAuthMiddleware>[0]>[0];
/**
* Array of all hooks to run after better-auth requests
*
* Add new hooks here to register them
*/
const afterHooks: Array<(context: AuthMiddlewareContext) => Promise<unknown>> = [
// add hooks here
];
/**
* Combines all after hooks into a single function
* This function will be called by better-auth after each request
*/
export const combineAfterHooks = async (context: AuthMiddlewareContext) => {
for (const hook of afterHooks) {
try {
await hook(context);
} catch (error) {
console.error('Error executing hook:', error);
}
}
};
+26
View File
@@ -0,0 +1,26 @@
import * as schema from '@db/schema';
import { User } from 'better-auth';
import { db } from '@db/drizzle';
import { eq } from 'drizzle-orm';
export const syncUserWithProfile = async (user: User) => {
console.debug('syncUserWithProfile', user);
if (!user?.id) {
return;
}
try {
// update profile and mark as verified
await db
.update(schema.profile)
.set({
email: user.email,
isEmailVerified: user.emailVerified
})
.where(eq(schema.profile.id, user.id));
} catch (error) {
console.error('Error marking profile as verified:', error);
}
};
+7
View File
@@ -0,0 +1,7 @@
const { TRUSTED_ORIGINS: TRUSTED_ORIGINS_STRING, SERVER_URL } = process.env;
export const TRUSTED_ORIGINS = TRUSTED_ORIGINS_STRING
? TRUSTED_ORIGINS_STRING.split(',')
: ['http://localhost:5173', 'http://*.classroomio.*'];
export const BASE_URL = SERVER_URL || 'http://localhost:3002';
+4 -6
View File
@@ -1,16 +1,14 @@
import 'dotenv/config';
import * as schema from './schema';
import { SQL, sql } from 'drizzle-orm';
import { drizzle } from 'drizzle-orm/node-postgres';
import { drizzle } from 'drizzle-orm/postgres-js';
// import * as schema from './schema';
const connectionString = process.env.DATABASE_URL ?? process.env.PRIVATE_DATABASE_URL ?? '';
export const db = drizzle(connectionString, {
schema
});
export const db = drizzle(connectionString);
export * from 'drizzle-orm';
export * from './schema';
+6
View File
@@ -0,0 +1,6 @@
// Main entry point for @cio/db package
export * from './drizzle';
export * from './schema';
export * from './relations';
export * from './auth';
export * from './types';
@@ -0,0 +1,22 @@
CREATE TABLE "verification" (
"id" uuid PRIMARY KEY DEFAULT uuid_generate_v4() NOT NULL,
"identifier" text NOT NULL,
"value" text NOT NULL,
"expires_at" timestamp NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "account" DROP CONSTRAINT "account_user_id_user_id_fk";--> statement-breakpoint
ALTER TABLE "account" ALTER COLUMN "id" SET DEFAULT uuid_generate_v4();--> statement-breakpoint
ALTER TABLE "account" ALTER COLUMN "user_id" SET DATA TYPE uuid USING user_id::uuid;--> statement-breakpoint
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user" ALTER COLUMN "id" SET DATA TYPE uuid USING id::uuid;--> statement-breakpoint
ALTER TABLE "user" ALTER COLUMN "id" SET DEFAULT uuid_generate_v4();--> statement-breakpoint
ALTER TABLE "session" DROP CONSTRAINT "session_user_id_user_id_fk";--> statement-breakpoint
ALTER TABLE "session" ALTER COLUMN "id" SET DATA TYPE uuid USING id::uuid;--> statement-breakpoint
ALTER TABLE "session" ALTER COLUMN "id" SET DEFAULT uuid_generate_v4();--> statement-breakpoint
ALTER TABLE "session" ALTER COLUMN "user_id" SET DATA TYPE uuid USING user_id::uuid;--> statement-breakpoint
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
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
@@ -15,6 +15,13 @@
"when": 1760912065001,
"tag": "0001_tranquil_thunderbolts",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1762733677656,
"tag": "0002_many_revanche",
"breakpoints": true
}
]
}
}
+21
View File
@@ -0,0 +1,21 @@
import { customSession } from 'better-auth/plugins';
import { getProfileById } from './profile';
import { getOrganizationByProfileId } from '../organization';
export const getCustomSession = () =>
customSession(async ({ user, session }) => {
const profile = await getProfileById(user.id);
const organizations = await getOrganizationByProfileId(user.id);
return {
user: {
id: user.id,
email: user.email,
createdAt: user.createdAt,
updatedAt: user.updatedAt
},
profile,
session,
organizations
};
});
+2
View File
@@ -0,0 +1,2 @@
export * from './auth';
export * from './profile';
+17
View File
@@ -0,0 +1,17 @@
import * as schema from '@db/schema';
import type { TProfile } from '@db/types';
import { db } from '@db/drizzle';
import { eq } 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);
return profile;
};
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();
return updatedProfile;
};
+50
View File
@@ -0,0 +1,50 @@
import * as schema from '@db/schema';
import { and, eq, sql } from 'drizzle-orm';
import { db } from '@db/drizzle';
/**
* Gets courses by organization siteName
* @param siteName Organization site name
* @returns Array of courses with lesson counts and organization info
*/
export const getCoursesBySiteName = async (siteName: string) => {
const result = await db
.select({
course: schema.course,
organization: {
id: schema.organization.id,
name: schema.organization.name,
siteName: schema.organization.siteName,
avatarUrl: schema.organization.avatarUrl
},
lessonCount: sql<number>`COUNT(${schema.lesson.id})`.as('lesson_count')
})
.from(schema.course)
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
.innerJoin(schema.organization, eq(schema.group.organizationId, schema.organization.id))
.leftJoin(schema.lesson, eq(schema.course.id, schema.lesson.courseId))
.where(
and(
eq(schema.organization.siteName, siteName),
eq(schema.course.status, 'ACTIVE'),
eq(schema.course.isPublished, true)
)
)
.groupBy(
schema.course.id,
schema.organization.id,
schema.organization.name,
schema.organization.siteName,
schema.organization.avatarUrl
);
return result.map((row) => ({
...row.course,
lessons: [{ count: Number(row.lessonCount) }],
group: {
organization: row.organization
}
}));
};
+1
View File
@@ -0,0 +1 @@
export * from './course';
+3
View File
@@ -0,0 +1,3 @@
export * from './organization';
export * from './auth';
export * from './course';
@@ -0,0 +1 @@
export * from './organization';
@@ -0,0 +1,307 @@
import * as schema from '@db/schema';
import type { OrganizationPlan, OrganizationWithMemberAndPlans, OrganizationWithPlans } from './types';
import type {
TNewOrganization,
TNewOrganizationPlan,
TNewOrganizationmember,
TOrganization,
TOrganizationPlan
} from '@db/types';
import { and, eq, sql } from 'drizzle-orm';
import { ROLE } from '@cio/utils/constants';
import { db } from '@db/drizzle';
export const getOrganizationByProfileId = async (profileId: string): Promise<OrganizationWithMemberAndPlans[]> => {
const result = await db
.select({
organization: schema.organization,
roleId: schema.organizationmember.roleId,
plan: {
planName: schema.organizationPlan.planName,
isActive: schema.organizationPlan.isActive,
provider: schema.organizationPlan.provider,
subscriptionId: schema.organizationPlan.subscriptionId,
customerId: sql`organization_plan.payload->>'customerId'`.as('customerId')
}
})
.from(schema.organization)
.leftJoin(schema.organizationmember, eq(schema.organization.id, schema.organizationmember.organizationId))
.leftJoin(schema.organizationPlan, eq(schema.organization.id, schema.organizationPlan.orgId))
.where(eq(schema.organizationmember.profileId, profileId));
// Group by organization and collect plans into an array
const organizationMap = new Map<
string,
{
organization: typeof schema.organization.$inferSelect;
roleId: number | undefined;
plans: Array<OrganizationPlan>;
}
>();
for (const row of result) {
const orgId = row.organization.id;
if (!organizationMap.has(orgId)) {
organizationMap.set(orgId, {
organization: row.organization,
roleId: row.roleId ?? undefined,
plans: []
});
}
const orgData = organizationMap.get(orgId)!;
// Add plan to array if it exists (not null)
if (
row.plan &&
(row.plan.planName !== null ||
row.plan.isActive !== null ||
row.plan.provider !== null ||
row.plan.subscriptionId !== null)
) {
orgData.plans.push({
planName: row.plan.planName,
isActive: row.plan.isActive,
provider: row.plan.provider,
subscriptionId: row.plan.subscriptionId,
customerId: row.plan.customerId as string | null
});
}
}
return Array.from(organizationMap.values()).map(({ organization, ...rest }) => ({
...(organization as TOrganization),
...rest
}));
};
export const createOrganization = async (data: TNewOrganization) => {
const [organization] = await db.insert(schema.organization).values(data).returning();
return organization;
};
export const createOrganizationMember = async (data: TNewOrganizationmember) => {
const [member] = await db.insert(schema.organizationmember).values(data).returning();
return member;
};
export const checkSiteNameExists = async (siteName: string): Promise<boolean> => {
const result = await db
.select({ id: schema.organization.id })
.from(schema.organization)
.where(eq(schema.organization.siteName, siteName))
.limit(1);
console.debug('checkSiteNameExists result:', result);
return result.length > 0;
};
export const deleteOrganizationById = async (id: string) => {
await db.delete(schema.organization).where(eq(schema.organization.id, id));
};
/**
* Gets organization team members (non-students)
* @param orgId Organization ID
* @returns Array of team members with profile information
*/
export const getOrganizationTeam = async (orgId: string) => {
const result = await db
.select({
id: schema.organizationmember.id,
email: schema.organizationmember.email,
verified: schema.organizationmember.verified,
roleId: schema.organizationmember.roleId,
profile: {
id: schema.profile.id,
fullname: schema.profile.fullname,
email: schema.profile.email
}
})
.from(schema.organizationmember)
.leftJoin(schema.profile, eq(schema.organizationmember.profileId, schema.profile.id))
.where(
and(
eq(schema.organizationmember.organizationId, orgId),
sql`${schema.organizationmember.roleId} != ${ROLE.STUDENT}`
)
)
.orderBy(sql`${schema.organizationmember.id} DESC`);
return result.map((member) => ({
id: member.id,
email: member.profile?.email || member.email || '',
verified: member.verified,
roleId: member.roleId,
profileId: member.profile?.id,
fullname: member.profile?.fullname || ''
}));
};
/**
* Gets organization audience (students who are participants in any course)
* @param orgId Organization ID
* @returns Array of student profiles
*/
export const getOrganizationAudience = async (orgId: string) => {
const result = await db
.select({
id: schema.profile.id,
fullname: schema.profile.fullname,
email: schema.profile.email,
avatarUrl: schema.profile.avatarUrl,
createdAt: schema.profile.createdAt
})
.from(schema.profile)
.innerJoin(schema.groupmember, eq(schema.profile.id, schema.groupmember.profileId))
.innerJoin(schema.group, eq(schema.groupmember.groupId, schema.group.id))
.where(and(eq(schema.group.organizationId, orgId), eq(schema.groupmember.roleId, ROLE.STUDENT)));
return result.map((profile) => ({
id: profile.id,
name: profile.fullname,
email: profile.email || '',
avatar_url: profile.avatarUrl || '',
date_joined: profile.createdAt ? new Date(profile.createdAt).toDateString() : ''
}));
};
/**
* Gets organizations with optional filters
* @param filters Filter options (siteName, customDomain, isCustomDomainVerified)
* @returns Array of organizations with plans
*/
export const getOrganizations = async (filters?: {
siteName?: string;
customDomain?: string;
isCustomDomainVerified?: boolean;
}): Promise<OrganizationWithPlans[]> => {
const conditions: Parameters<typeof and>[0][] = [];
if (filters?.siteName) {
conditions.push(eq(schema.organization.siteName, filters.siteName));
}
if (filters?.customDomain) {
const customDomainConditions: Parameters<typeof and>[0][] = [
eq(schema.organization.customDomain, filters.customDomain)
];
if (filters.isCustomDomainVerified !== undefined) {
customDomainConditions.push(eq(schema.organization.isCustomDomainVerified, filters.isCustomDomainVerified));
}
conditions.push(and(...customDomainConditions));
}
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
const result = await db
.select({
organization: schema.organization,
plan: {
planName: schema.organizationPlan.planName,
isActive: schema.organizationPlan.isActive,
provider: schema.organizationPlan.provider,
subscriptionId: schema.organizationPlan.subscriptionId,
customerId: sql`organization_plan.payload->>'customerId'`.as('customerId')
}
})
.from(schema.organization)
.leftJoin(schema.organizationPlan, eq(schema.organization.id, schema.organizationPlan.orgId))
.where(whereClause);
// Group by organization and collect plans into an array
const organizationMap = new Map<
string,
{
organization: typeof schema.organization.$inferSelect;
plans: Array<OrganizationPlan>;
}
>();
for (const row of result) {
const orgId = row.organization.id;
if (!organizationMap.has(orgId)) {
organizationMap.set(orgId, {
organization: row.organization,
plans: []
});
}
const orgData = organizationMap.get(orgId)!;
// Add plan to array if it exists (not null)
if (
row.plan &&
(row.plan.planName !== null ||
row.plan.isActive !== null ||
row.plan.provider !== null ||
row.plan.subscriptionId !== null)
) {
orgData.plans.push({
planName: row.plan.planName,
isActive: row.plan.isActive,
provider: row.plan.provider,
subscriptionId: row.plan.subscriptionId,
customerId: row.plan.customerId as string | null
});
}
}
return Array.from(organizationMap.values()).map(({ organization, plans }) => ({
...(organization as TOrganization),
plans
}));
};
/**
* Creates a new organization plan
* @param data Organization plan creation data
* @returns Created organization plan record
*/
export const createOrganizationPlan = async (data: TNewOrganizationPlan) => {
const [plan] = await db.insert(schema.organizationPlan).values(data).returning();
return plan;
};
/**
* Updates an organization plan by subscription ID
* @param subscriptionId Subscription ID
* @param payload Payload data to update
* @returns Updated organization plan record
*/
export const updateOrganizationPlan = async (subscriptionId: string, payload: TOrganizationPlan['payload']) => {
const [plan] = await db
.update(schema.organizationPlan)
.set({ payload, updatedAt: sql`timezone('utc'::text, now())` })
.where(eq(schema.organizationPlan.subscriptionId, subscriptionId))
.returning();
return plan;
};
/**
* Cancels an organization plan by subscription ID
* @param subscriptionId Subscription ID
* @param payload Payload data to update
* @returns Updated organization plan record
*/
export const cancelOrganizationPlan = async (subscriptionId: string, payload: TOrganizationPlan['payload']) => {
const [plan] = await db
.update(schema.organizationPlan)
.set({
isActive: false,
deactivatedAt: sql`timezone('utc'::text, now())`,
payload,
updatedAt: sql`timezone('utc'::text, now())`
})
.where(eq(schema.organizationPlan.subscriptionId, subscriptionId))
.returning();
return plan;
};
@@ -0,0 +1,14 @@
import { TOrganization, TOrganizationPlan, TOrganizationmember } from '@db/types';
export type OrganizationPlan = Pick<TOrganizationPlan, 'planName' | 'isActive' | 'provider' | 'subscriptionId'> & {
customerId: string | null;
};
export type OrganizationWithMemberAndPlans = TOrganization & {
roleId?: number;
plans: OrganizationPlan[];
};
export type OrganizationWithPlans = TOrganization & {
plans: OrganizationPlan[];
};
+2 -2
View File
@@ -32,11 +32,11 @@ import {
quizPlay,
role,
submission,
submissionstatus
submissionstatus,
user
} from './schema';
import { relations } from 'drizzle-orm/relations';
import { user } from './auth-schema';
export const analyticsLoginEventsRelations = relations(analyticsLoginEvents, ({ one }) => ({
usersInAuth: one(user, {
+83 -24
View File
@@ -1,4 +1,3 @@
import { account, session, user } from './auth-schema';
import {
bigint,
boolean,
@@ -27,7 +26,83 @@ export const courseVersion = pgEnum('COURSE_VERSION', ['V1', 'V2']);
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 { user, session, account };
export const user = pgTable('user', {
id: uuid()
.default(sql`uuid_generate_v4()`)
.primaryKey()
.notNull(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
emailVerified: boolean('email_verified').default(false).notNull(),
image: text('image'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
role: text('role'),
banned: boolean('banned').default(false),
banReason: text('ban_reason'),
banExpires: timestamp('ban_expires'),
isAnonymous: boolean('is_anonymous')
});
export const session = pgTable('session', {
id: uuid()
.default(sql`uuid_generate_v4()`)
.primaryKey()
.notNull(),
expiresAt: timestamp('expires_at').notNull(),
token: text('token').notNull().unique(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
userId: uuid('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
impersonatedBy: text('impersonated_by')
});
export const account = pgTable('account', {
id: uuid('id')
.default(sql`uuid_generate_v4()`)
.primaryKey()
.notNull(),
accountId: text('account_id').notNull(),
providerId: text('provider_id').notNull(),
userId: uuid('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
idToken: text('id_token'),
accessTokenExpiresAt: timestamp('access_token_expires_at'),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
scope: text('scope'),
password: text('password'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull()
});
export const verification = pgTable('verification', {
id: uuid('id')
.default(sql`uuid_generate_v4()`)
.primaryKey()
.notNull(),
identifier: text('identifier').notNull(),
value: text('value').notNull(),
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull()
});
export const analyticsLoginEvents = pgTable(
'analytics_login_events',
@@ -127,7 +202,6 @@ export const groupAttendance = pgTable(
startWith: 1,
increment: 1,
minValue: 1,
maxValue: 9223372036854775807,
cache: 1
}),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow(),
@@ -170,7 +244,6 @@ export const currency = pgTable('currency', {
startWith: 1,
increment: 1,
minValue: 1,
maxValue: 9223372036854775807,
cache: 1
}),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow(),
@@ -186,7 +259,6 @@ export const appsPollOption = pgTable(
startWith: 1,
increment: 1,
minValue: 1,
maxValue: 9223372036854775807,
cache: 1
}),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
@@ -250,6 +322,8 @@ export const profile = pgTable(
}),
unique('profile_username_key').on(table.username),
unique('profile_email_key').on(table.email),
index('idx_profile_id').on(table.id),
index('idx_profile_email').on(table.email),
pgPolicy('Only auth users can read profile', {
as: 'permissive',
for: 'select',
@@ -272,7 +346,6 @@ export const option = pgTable(
startWith: 1,
increment: 1,
minValue: 1,
maxValue: 9223372036854775807,
cache: 1
}),
label: varchar().notNull(),
@@ -310,7 +383,6 @@ export const quizPlay = pgTable(
startWith: 1,
increment: 1,
minValue: 1,
maxValue: 9223372036854775807,
cache: 1
}),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow(),
@@ -351,7 +423,6 @@ export const organizationContacts = pgTable(
startWith: 1,
increment: 1,
minValue: 1,
maxValue: 9223372036854775807,
cache: 1
}),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
@@ -385,7 +456,6 @@ export const lessonComment = pgTable(
startWith: 1,
increment: 1,
minValue: 1,
maxValue: 9223372036854775807,
cache: 1
}),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
@@ -511,7 +581,6 @@ export const submissionstatus = pgTable(
startWith: 1,
increment: 1,
minValue: 1,
maxValue: 9223372036854775807,
cache: 1
}),
label: varchar().notNull(),
@@ -619,7 +688,6 @@ export const videoTranscripts = pgTable('video_transcripts', {
startWith: 1,
increment: 1,
minValue: 1,
maxValue: 9223372036854775807,
cache: 1
}),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
@@ -638,7 +706,6 @@ export const organizationEmaillist = pgTable(
startWith: 1,
increment: 1,
minValue: 1,
maxValue: 9223372036854775807,
cache: 1
}),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
@@ -720,7 +787,6 @@ export const appsPollSubmission = pgTable(
startWith: 1,
increment: 1,
minValue: 1,
maxValue: 9223372036854775807,
cache: 1
}),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
@@ -839,7 +905,6 @@ export const lessonCompletion = pgTable(
startWith: 1,
increment: 1,
minValue: 1,
maxValue: 9223372036854775807,
cache: 1
}),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
@@ -925,7 +990,6 @@ export const communityQuestion = pgTable(
startWith: 1,
increment: 1,
minValue: 1,
maxValue: 9223372036854775807,
cache: 1
}),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow(),
@@ -1021,7 +1085,6 @@ export const courseNewsfeedComment = pgTable(
startWith: 1,
increment: 1,
minValue: 1,
maxValue: 9223372036854775807,
cache: 1
}),
courseNewsfeedId: uuid('course_newsfeed_id')
@@ -1066,7 +1129,6 @@ export const organizationPlan = pgTable(
startWith: 1,
increment: 1,
minValue: 1,
maxValue: 9223372036854775807,
cache: 1
}),
activatedAt: timestamp('activated_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
@@ -1093,6 +1155,7 @@ export const organizationPlan = pgTable(
name: 'organization_plan_triggered_by_fkey'
}),
unique('organization_plan_subscription_id_key').on(table.subscriptionId),
index('idx_organization_plan_org_id').on(table.orgId),
pgPolicy('Enable read access for all users', { as: 'permissive', for: 'select', to: ['public'], using: sql`true` }),
pgPolicy('User must be an org member to DELETE', { as: 'permissive', for: 'delete', to: ['public'] }),
pgPolicy('User must be an org member to INSERT', { as: 'permissive', for: 'insert', to: ['public'] }),
@@ -1109,7 +1172,6 @@ export const lessonLanguage = pgTable(
startWith: 1,
increment: 1,
minValue: 1,
maxValue: 9223372036854775807,
cache: 1
}),
content: text(),
@@ -1171,7 +1233,6 @@ export const organizationmember = pgTable(
startWith: 1,
increment: 1,
minValue: 1,
maxValue: 9223372036854775807,
cache: 1
}),
organizationId: uuid('organization_id').notNull(),
@@ -1200,6 +1261,9 @@ export const organizationmember = pgTable(
foreignColumns: [role.id],
name: 'organizationmember_role_id_fkey'
}),
index('idx_organizationmember_profile_id').on(table.profileId),
index('idx_organizationmember_organization_id').on(table.organizationId),
index('idx_organizationmember_profile_org').on(table.profileId, table.organizationId),
pgPolicy('Allow authenticated users to read.', {
as: 'permissive',
for: 'select',
@@ -1227,7 +1291,6 @@ export const question = pgTable(
startWith: 1,
increment: 1,
minValue: 1,
maxValue: 9223372036854775807,
cache: 1
}),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
@@ -1273,7 +1336,6 @@ export const questionAnswer = pgTable(
startWith: 1,
increment: 1,
minValue: 1,
maxValue: 9223372036854775807,
cache: 1
}),
answers: varchar().array(),
@@ -1322,7 +1384,6 @@ export const questionType = pgTable(
startWith: 1,
increment: 1,
minValue: 1,
maxValue: 9223372036854775807,
cache: 1
}),
label: varchar().notNull(),
@@ -1346,7 +1407,6 @@ export const role = pgTable(
startWith: 1,
increment: 1,
minValue: 1,
maxValue: 9223372036854775807,
cache: 1
}),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow(),
@@ -1366,7 +1426,6 @@ export const waitinglist = pgTable(
startWith: 1,
increment: 1,
minValue: 1,
maxValue: 9223372036854775807,
cache: 1
}),
email: varchar().notNull(),
+104
View File
@@ -0,0 +1,104 @@
import 'dotenv/config';
import { db, questionType, role, submissionstatus } from '../drizzle';
async function seed() {
try {
console.log('🌱 Starting seed...');
// Seed roles
console.log('📝 Seeding roles...');
const existingRoles = await db.select().from(role);
const existingRoleIds = existingRoles.map((r) => r.id);
const rolesToInsert = [
{
id: 1,
type: 'ADMIN',
description: 'The main controller'
},
{
id: 2,
type: 'TUTOR',
description: 'Can make changes to content, courses, but cant control passwords and cant add other tutors'
},
{
id: 3,
type: 'STUDENT',
description: 'A student role, can interact with application but cant make changes'
}
].filter((r) => !existingRoleIds.includes(r.id));
if (rolesToInsert.length > 0) {
await db.insert(role).values(rolesToInsert);
console.log(` ✓ Inserted ${rolesToInsert.length} role(s)`);
} else {
console.log(' ✓ Roles already exist, skipping');
}
// Seed submission statuses
console.log('📝 Seeding submission statuses...');
const existingStatuses = await db.select().from(submissionstatus);
const statusLabels = existingStatuses.map((s) => s.label);
const statusesToInsert = [
{
label: 'Submitted'
},
{
label: 'In Progress'
},
{
label: 'Graded'
}
].filter((s) => !statusLabels.includes(s.label));
if (statusesToInsert.length > 0) {
await db.insert(submissionstatus).values(statusesToInsert);
console.log(` ✓ Inserted ${statusesToInsert.length} submission status(es)`);
} else {
console.log(' ✓ Submission statuses already exist, skipping');
}
// Seed question types
console.log('📝 Seeding question types...');
const existingQuestionTypes = await db.select().from(questionType);
const questionTypeNames = existingQuestionTypes.map((q) => q.typename);
const questionTypesToInsert = [
{
label: 'Single answer',
typename: 'RADIO',
createdAt: '2021-08-07 18:49:46.246529+00',
updatedAt: '2021-08-15 00:57:08.12069+00'
},
{
label: 'Multiple answers',
typename: 'CHECKBOX',
createdAt: '2021-08-07 18:49:46.246529+00',
updatedAt: '2021-08-15 00:57:27.935478+00'
},
{
label: 'Paragraph',
typename: 'TEXTAREA',
createdAt: '2021-08-07 18:49:46.246529+00',
updatedAt: '2021-08-15 00:57:38.634665+00'
}
].filter((q) => !questionTypeNames.includes(q.typename));
if (questionTypesToInsert.length > 0) {
await db.insert(questionType).values(questionTypesToInsert);
console.log(` ✓ Inserted ${questionTypesToInsert.length} question type(s)`);
} else {
console.log(' ✓ Question types already exist, skipping');
}
console.log('✅ Seed completed successfully!');
process.exit(0);
} catch (error) {
console.error('❌ Error seeding database:', error);
process.exit(1);
}
}
seed();
+139
View File
@@ -0,0 +1,139 @@
// Database types generated from schema using Drizzle's built-in type inference
// This file contains all TypeScript types for the database
// Naming convention: T prefix for types
import * as schema from './schema';
export type TUser = typeof schema.user.$inferSelect;
export type TNewUser = typeof schema.user.$inferInsert;
export type TSession = typeof schema.session.$inferSelect;
export type TNewSession = typeof schema.session.$inferInsert;
export type TAccount = typeof schema.account.$inferSelect;
export type TNewAccount = typeof schema.account.$inferInsert;
export type TVerification = typeof schema.verification.$inferSelect;
export type TNewVerification = typeof schema.verification.$inferInsert;
export type TAnalyticsLoginEvents = typeof schema.analyticsLoginEvents.$inferSelect;
export type TNewAnalyticsLoginEvents = typeof schema.analyticsLoginEvents.$inferInsert;
export type TLessonSection = typeof schema.lessonSection.$inferSelect;
export type TNewLessonSection = typeof schema.lessonSection.$inferInsert;
export type TGroup = typeof schema.group.$inferSelect;
export type TNewGroup = typeof schema.group.$inferInsert;
export type TGroupAttendance = typeof schema.groupAttendance.$inferSelect;
export type TNewGroupAttendance = typeof schema.groupAttendance.$inferInsert;
export type TCurrency = typeof schema.currency.$inferSelect;
export type TNewCurrency = typeof schema.currency.$inferInsert;
export type TAppsPollOption = typeof schema.appsPollOption.$inferSelect;
export type TNewAppsPollOption = typeof schema.appsPollOption.$inferInsert;
export type TProfile = typeof schema.profile.$inferSelect;
export type TNewProfile = typeof schema.profile.$inferInsert;
export type TOption = typeof schema.option.$inferSelect;
export type TNewOption = typeof schema.option.$inferInsert;
export type TQuizPlay = typeof schema.quizPlay.$inferSelect;
export type TNewQuizPlay = typeof schema.quizPlay.$inferInsert;
export type TOrganizationContacts = typeof schema.organizationContacts.$inferSelect;
export type TNewOrganizationContacts = typeof schema.organizationContacts.$inferInsert;
export type TLessonComment = typeof schema.lessonComment.$inferSelect;
export type TNewLessonComment = typeof schema.lessonComment.$inferInsert;
export type TQuiz = typeof schema.quiz.$inferSelect;
export type TNewQuiz = typeof schema.quiz.$inferInsert;
export type TSubmission = typeof schema.submission.$inferSelect;
export type TNewSubmission = typeof schema.submission.$inferInsert;
export type TSubmissionstatus = typeof schema.submissionstatus.$inferSelect;
export type TNewSubmissionstatus = typeof schema.submissionstatus.$inferInsert;
export type TCourse = typeof schema.course.$inferSelect;
export type TNewCourse = typeof schema.course.$inferInsert;
export type TAppsPoll = typeof schema.appsPoll.$inferSelect;
export type TNewAppsPoll = typeof schema.appsPoll.$inferInsert;
export type TVideoTranscripts = typeof schema.videoTranscripts.$inferSelect;
export type TNewVideoTranscripts = typeof schema.videoTranscripts.$inferInsert;
export type TOrganizationEmaillist = typeof schema.organizationEmaillist.$inferSelect;
export type TNewOrganizationEmaillist = typeof schema.organizationEmaillist.$inferInsert;
export type TLesson = typeof schema.lesson.$inferSelect;
export type TNewLesson = typeof schema.lesson.$inferInsert;
export type TAppsPollSubmission = typeof schema.appsPollSubmission.$inferSelect;
export type TNewAppsPollSubmission = typeof schema.appsPollSubmission.$inferInsert;
export type TExercise = typeof schema.exercise.$inferSelect;
export type TNewExercise = typeof schema.exercise.$inferInsert;
export type TGroupmember = typeof schema.groupmember.$inferSelect;
export type TNewGroupmember = typeof schema.groupmember.$inferInsert;
export type TLessonCompletion = typeof schema.lessonCompletion.$inferSelect;
export type TNewLessonCompletion = typeof schema.lessonCompletion.$inferInsert;
export type TCommunityAnswer = typeof schema.communityAnswer.$inferSelect;
export type TNewCommunityAnswer = typeof schema.communityAnswer.$inferInsert;
export type TCommunityQuestion = typeof schema.communityQuestion.$inferSelect;
export type TNewCommunityQuestion = typeof schema.communityQuestion.$inferInsert;
export type TCourseNewsfeed = typeof schema.courseNewsfeed.$inferSelect;
export type TNewCourseNewsfeed = typeof schema.courseNewsfeed.$inferInsert;
export type TCourseNewsfeedComment = typeof schema.courseNewsfeedComment.$inferSelect;
export type TNewCourseNewsfeedComment = typeof schema.courseNewsfeedComment.$inferInsert;
export type TTestTenant = typeof schema.testTenant.$inferSelect;
export type TNewTestTenant = typeof schema.testTenant.$inferInsert;
export type TOrganizationPlan = typeof schema.organizationPlan.$inferSelect;
export type TNewOrganizationPlan = typeof schema.organizationPlan.$inferInsert;
export type TLessonLanguage = typeof schema.lessonLanguage.$inferSelect;
export type TNewLessonLanguage = typeof schema.lessonLanguage.$inferInsert;
export type TLessonLanguageHistory = typeof schema.lessonLanguageHistory.$inferSelect;
export type TNewLessonLanguageHistory = typeof schema.lessonLanguageHistory.$inferInsert;
export type TOrganizationmember = typeof schema.organizationmember.$inferSelect;
export type TNewOrganizationmember = typeof schema.organizationmember.$inferInsert;
export type TQuestion = typeof schema.question.$inferSelect;
export type TNewQuestion = typeof schema.question.$inferInsert;
export type TQuestionAnswer = typeof schema.questionAnswer.$inferSelect;
export type TNewQuestionAnswer = typeof schema.questionAnswer.$inferInsert;
export type TQuestionType = typeof schema.questionType.$inferSelect;
export type TNewQuestionType = typeof schema.questionType.$inferInsert;
export type TRole = typeof schema.role.$inferSelect;
export type TNewRole = typeof schema.role.$inferInsert;
export type TWaitinglist = typeof schema.waitinglist.$inferSelect;
export type TNewWaitinglist = typeof schema.waitinglist.$inferInsert;
export type TOrganization = typeof schema.organization.$inferSelect;
export type TNewOrganization = typeof schema.organization.$inferInsert;
export type TCourseType = (typeof schema.courseType.enumValues)[number];
export type TCourseVersion = (typeof schema.courseVersion.enumValues)[number];
export type TLocale = (typeof schema.locale.enumValues)[number];
export type TPlan = (typeof schema.plan.enumValues)[number];