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
+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[];
};