Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
b246937
docs: plan tiers, trial and limits design
s3rot0n1n Nov 14, 2026
ef14c01
feat(api): bill school plans through lemon squeezy instead of dodo
s3rot0n1n Nov 14, 2026
7e7fd71
feat(api): school states, schoolPlan and plan limits for courselit plans
s3rot0n1n Nov 14, 2026
50c5d1f
feat(api): set sendlit marketing quotas from the school's plan
s3rot0n1n Nov 14, 2026
00f0545
feat(api): send learner mail through platform streams with caps and a…
s3rot0n1n Nov 14, 2026
3730ac0
feat: trials, school lifecycle, plan features and the courselit badge
s3rot0n1n Nov 14, 2026
011ad37
feat: limit invites to 50 a day and 200 pending per school
s3rot0n1n Nov 14, 2026
be17592
feat: enforce storage limits at finalize and give learners a budget
s3rot0n1n Nov 14, 2026
9660d65
feat: show plan usage and warn the owner at 80% of a limit
s3rot0n1n Nov 14, 2026
8cbe973
feat: add report this site, an acceptable use policy and reserved sub…
s3rot0n1n Nov 14, 2026
617fc92
docs: plans, trial, limits, branding badge, audit log and storage
s3rot0n1n Nov 14, 2026
f01347e
test(api): build the fake billing encryption key at runtime
s3rot0n1n Nov 14, 2026
6a5b70f
fix(api): count finished learner uploads toward the hourly limit
s3rot0n1n Nov 15, 2026
eec0a14
fix(storefront): don't tell enrolled learners they aren't enrolled
s3rot0n1n Nov 15, 2026
acde705
fix(storefront): say an empty lesson has no content in preview too
s3rot0n1n Nov 15, 2026
201349b
feat(admin): show what each plan includes when picking one
s3rot0n1n Nov 15, 2026
cc733af
fix(admin): put the setup wizard's scrollbar on the pane's edge
s3rot0n1n Nov 15, 2026
5a0c680
feat: set plan limits by environment; pro $29 with unlimited storage
s3rot0n1n Nov 15, 2026
8452366
fix(api): keep a new sendlit team when a later provisioning step fails
s3rot0n1n Nov 15, 2026
f637468
test(api): configure plan limits in the test preload
s3rot0n1n Nov 15, 2026
3716426
feat(api): send replies to a school's marketing email to its owner
s3rot0n1n Nov 15, 2026
c47917b
Merge pull request #886 from codelitdev/feat/marketing-sender-reply-to
s3rot0n1n Nov 15, 2026
c765cbc
Merge remote-tracking branch 'origin/platform-based-rewrite' into fea…
s3rot0n1n Nov 15, 2026
04f93bd
chore(api): regenerate openapi for the plan endpoints
s3rot0n1n Nov 15, 2026
b6a35bb
docs(frontlit): show the new plan limits on the site and in the docs
s3rot0n1n Nov 15, 2026
b0af09b
Merge remote-tracking branch 'origin/feat/plan-limits' into feat/plan…
s3rot0n1n Nov 15, 2026
87047aa
docs: mark the plan limits work as built and list what's deferred
s3rot0n1n Nov 15, 2026
9380a2b
feat(api): send learner mail under the school's name
s3rot0n1n Nov 15, 2026
6c63a95
fix(api): drop @ and invisible characters from a school's sender name
s3rot0n1n Nov 15, 2026
ea7395e
docs: note that sendlit's per-message sender name is merged
s3rot0n1n Nov 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/admin/app/onboarding/setup/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ function SetupWizardRoute() {
isNew={params.get("new") === "1"}
checkoutReturn={params.get("checkout") === "return"}
resume={params.get("resume") === "1"}
choosePlan={params.get("plan") === "1"}
/>
);
}
Expand Down
40 changes: 39 additions & 1 deletion apps/admin/app/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
Palette,
RotateCcw,
Save,
HardDrive,
ScrollText,
Sparkles,
Users,
} from "lucide-react";
Expand All @@ -25,6 +27,8 @@ import { useEffect, useState } from "react";
import { AuthGate } from "@/components/auth-gate";
import { AiAssistantSettings } from "@/components/settings/ai-assistant-settings";
import { ApiKeySettings } from "@/components/settings/api-key-settings";
import { AuditLogSettings } from "@/components/settings/audit-log-settings";
import { StorageSettings } from "@/components/settings/storage-settings";
import { BrandingSettings } from "@/components/settings/branding-settings";
import { EmailDeliveryNotice } from "@/components/mails/email-delivery-notice";
import { LoginProviderSettings } from "@/components/settings/login-provider-settings";
Expand Down Expand Up @@ -60,7 +64,7 @@ import {
import { Textarea } from "@/components/ui/codelit/textarea";
import currencies from "@/data/currencies.json";

const GENERAL_SETTINGS_TABS = ["payment", "team", "notifications", "login-providers", "api-keys", "ai-assistants"] as const;
const GENERAL_SETTINGS_TABS = ["payment", "team", "notifications", "login-providers", "api-keys", "ai-assistants", "storage", "audit-log"] as const;
const MAIL_SETTINGS_TABS = ["delivery"] as const;
const WEBSITE_SETTINGS_TABS = ["branding", "code-injection", "domain"] as const;
type SettingsMode = "general" | "website" | "mails";
Expand Down Expand Up @@ -111,6 +115,7 @@ type SchoolItem = {
currency: string;
isOwner?: boolean;
selected?: boolean;
permissions?: string[];
};


Expand Down Expand Up @@ -192,6 +197,7 @@ export function SettingsPage({ mode = "general" }: { mode?: SettingsMode }) {

const [schoolId, setSchoolId] = useState<string | null>(null);
const [isOwner, setIsOwner] = useState(false);
const [canManageSchool, setCanManageSchool] = useState(false);

const providerSettings = isPaymentProvider(paymentMethod)
? paymentSettings?.[paymentMethod]
Expand All @@ -208,6 +214,7 @@ export function SettingsPage({ mode = "general" }: { mode?: SettingsMode }) {
if (!selected) return;
setSchoolId(selected.id);
setIsOwner(selected.isOwner === true);
setCanManageSchool(selected.permissions?.includes("school:write") === true);
setCurrency(selected.currency || "USD");
void fetch("/api/v1/school/payment-settings", {
credentials: "include",
Expand Down Expand Up @@ -444,6 +451,21 @@ export function SettingsPage({ mode = "general" }: { mode?: SettingsMode }) {
),
icon: <Sparkles className="size-4" />,
},
// Storage and the audit log are for people who manage the school's settings.
...(canManageSchool
? [
{
value: "storage",
label: "Storage",
icon: <HardDrive className="size-4" />,
},
{
value: "audit-log",
label: "Audit log",
icon: <ScrollText className="size-4" />,
},
]
: []),
]
}
>
Expand Down Expand Up @@ -765,6 +787,22 @@ export function SettingsPage({ mode = "general" }: { mode?: SettingsMode }) {
) : null}
</PlatformTabsContent>

<PlatformTabsContent value="storage" className="w-full">
{mode === "general" && selectedTab === "storage" && canManageSchool ? (
<div className="w-full pt-4">
<StorageSettings schoolId={schoolId} />
</div>
) : null}
</PlatformTabsContent>

<PlatformTabsContent value="audit-log" className="w-full">
{mode === "general" && selectedTab === "audit-log" && canManageSchool ? (
<div className="w-full pt-4">
<AuditLogSettings schoolId={schoolId} />
</div>
) : null}
</PlatformTabsContent>

<PlatformTabsContent value="notifications" className="w-full">
{mode === "general" && selectedTab === "notifications" ? (
<div className="w-full pt-4">
Expand Down
3 changes: 2 additions & 1 deletion apps/admin/components/communities/community-admin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
StatusBadge,
type StorefrontPlan,
} from "./community-shared";
import { inviteErrorMessage } from "@/lib/invite-errors";

export type CommunityManageSection =
| "details"
Expand All @@ -66,7 +67,7 @@ function communityApiErrorMessage(error: ApiError | null, fallback: string) {
if (error?.details?.reason === "community_offline") {
return "Make the community live before inviting people.";
}
return error?.message ?? fallback;
return inviteErrorMessage(error, fallback);
}

/** "3", or "10+" when there are more pending items than one page. */
Expand Down
6 changes: 5 additions & 1 deletion apps/admin/components/layout/admin-breadcrumb.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -192,14 +192,18 @@ export function getFallbackBreadcrumbs(pathname: string): BreadcrumbItemType[] {
clean === "/settings/team" ||
clean === "/settings/notifications" ||
clean === "/settings/api-keys" ||
clean === "/settings/ai-assistants"
clean === "/settings/ai-assistants" ||
clean === "/settings/audit-log" ||
clean === "/settings/storage"
) {
const tabLabels: Record<string, string> = {
payment: "Payments",
team: "Team",
notifications: "Notifications",
"api-keys": "API keys",
"ai-assistants": "AI assistants",
"audit-log": "Audit log",
storage: "Storage",
};
const tab = clean.split("/")[2];
return [{ label: "Settings", href: "/settings" }, { label: tabLabels[tab] }];
Expand Down
4 changes: 4 additions & 0 deletions apps/admin/components/layout/admin-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { BreadcrumbProvider } from "@/components/layout/breadcrumb-context";
import type { CurrentAccount } from "@/components/layout/nav-user";
import type { School } from "@/components/layout/team-switcher";
import { NotificationsBell } from "@/components/notifications/notifications-bell";
import { SchoolStateBanner } from "@/components/layout/school-state-banner";
import { PermissionDeniedFeedback } from "@/components/permission-denied-feedback";
import { CourseLitLoadingIcon } from "@/components/loading";
import { Separator } from "@/components/ui/separator";
Expand Down Expand Up @@ -320,6 +321,9 @@ export function AdminShell({ children }: { children: React.ReactNode }) {
aria-hidden={showLoadingOverlay || undefined}
className={`h-full overflow-auto p-6 ${showLoadingOverlay ? "invisible" : ""}`}
>
<SchoolStateBanner
school={schools.find((school) => school.selected)}
/>
{children}
</div>
) : null}
Expand Down
40 changes: 40 additions & 0 deletions apps/admin/components/layout/school-state-banner.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, expect, it } from "bun:test";
import { schoolStateNotice } from "./school-state-banner";

describe("schoolStateNotice", () => {
it("says nothing about a running school", () => {
expect(
schoolStateNotice({ name: "A", status: "active", isOwner: true }),
).toBeNull();
expect(schoolStateNotice({ name: "A", status: undefined })).toBeNull();
});

it("sends the owner of a read-only school to pick a plan", () => {
const notice = schoolStateNotice({ name: "A", status: "read_only", isOwner: true });
expect(notice?.text).toContain("read-only");
expect(notice?.actionLabel).toBe("Choose a plan");
});

it("tells the rest of the team to ask the owner", () => {
const notice = schoolStateNotice({
name: "A",
status: "read_only",
isOwner: false,
});
expect(notice?.text).toContain("its owner");
expect(notice?.actionLabel).not.toBe("Choose a plan");
});

it("opens the plan step for a school waiting on checkout", () => {
for (const status of ["pending_checkout", "imported"]) {
const notice = schoolStateNotice({ name: "A", status, isOwner: true });
expect(notice?.actionHref).toBe("/onboarding/setup?plan=1");
}
});

it("points a suspended school at support", () => {
expect(
schoolStateNotice({ name: "A", status: "suspended", isOwner: true })?.text,
).toContain("support@courselit.app");
});
});
81 changes: 81 additions & 0 deletions apps/admin/components/layout/school-state-banner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"use client";

import type { School } from "@/components/layout/team-switcher";
import {
type LimitUsage,
limitNearlyUsed,
usageShare,
useSchoolUsage,
} from "@/components/settings/usage-meters";
import { StatusBanner } from "@/components/status-banner";

const PLAN_HREF = "/account/billing";

/**
* What the admin says about a school that isn't fully running, and where it
* sends the reader. Only the owner can pick a plan, so the rest of the team is
* told to ask them. Null for a school that needs no notice.
*/
export function schoolStateNotice(
school: Pick<School, "name" | "status" | "isOwner">,
): { text: string; actionLabel: string; actionHref: string } | null {
const owner = school.isOwner === true;
switch (school.status) {
case "read_only":
return {
text: owner
? `${school.name} is read-only. Learners keep their access, but nothing can change until you pick a plan.`
: `${school.name} is read-only until its owner picks a plan.`,
actionLabel: owner ? "Choose a plan" : "See the plan",
actionHref: PLAN_HREF,
};
case "pending_checkout":
case "imported":
return {
text: owner
? `${school.name} isn't live yet. Pick a plan to open it to learners.`
: `${school.name} isn't live until its owner picks a plan.`,
actionLabel: owner ? "Choose a plan" : "See the plan",
actionHref: owner ? "/onboarding/setup?plan=1" : PLAN_HREF,
};
case "suspended":
return {
text: `${school.name} is suspended. Write to support@courselit.app to find out why.`,
actionLabel: "See the plan",
actionHref: PLAN_HREF,
};
default:
return null;
}
}

/** "M4 Academy has used 85% of its storage." for the owner's banner. */
export function usageWarning(schoolName: string, usage: LimitUsage): string {
const percent = Math.round((usageShare(usage) ?? 0) * 100);
return usage.key === "storage_bytes"
? `${schoolName} has used ${percent}% of its storage.`
: `${schoolName} has sent ${percent}% of this month's marketing email.`;
}

export function SchoolStateBanner({ school }: { school: School | undefined }) {
const notice = school ? schoolStateNotice(school) : null;
// Usage is billing information, so only the owner sees this warning (S9).
const usage = useSchoolUsage(
school?.id ?? null,
Boolean(school?.isOwner) && !notice && school?.status === "active",
);
const nearlyUsed = usage ? limitNearlyUsed(usage) : null;
if (!notice && nearlyUsed && school) {
return (
<StatusBanner actionLabel="See your plan" actionHref={PLAN_HREF}>
{usageWarning(school.name, nearlyUsed)}
</StatusBanner>
);
}
if (!notice) return null;
return (
<StatusBanner actionLabel={notice.actionLabel} actionHref={notice.actionHref}>
{notice.text}
</StatusBanner>
);
}
10 changes: 9 additions & 1 deletion apps/admin/components/onboarding/wizard/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
uploadedToMediaRef,
type WizardData,
} from "./flow";
import type { CourseLitPlanLimits } from "@courselit/api-contract/courselit-plans";

export class ApiError extends Error {
status: number;
Expand Down Expand Up @@ -80,6 +81,8 @@ export type BillingCatalog = {
amountMinor: number;
trialDays: number;
}>;
/** What each plan allows here; `null` values are unlimited. */
limits?: Record<PlanId, CourseLitPlanLimits> | null;
};

/** OSS = no catalog and no checkout. Everything else is the hosted service. */
Expand All @@ -95,7 +98,12 @@ export type SchoolOnboarding = {
checklistHidden: boolean;
resumePromptHidden: boolean;
checklist: Array<{ key: string; done: boolean }>;
billing: { required: boolean; entitled: boolean };
billing: {
required: boolean;
entitled: boolean;
paid: boolean;
trialEndsAt: string | null;
};
updatedAt: string;
};

Expand Down
25 changes: 25 additions & 0 deletions apps/admin/components/onboarding/wizard/flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
canSubmitSubdomain,
deriveSiteDomain,
detectLocaleDefaults,
ERROR_COPY,
emptyWizardData,
formatMailingAddress,
hasNewConnection,
Expand All @@ -17,6 +18,7 @@ import {
parseDraft,
previousStep,
resolveResumeStep,
resumeRows,
sanitizeSubdomainInput,
serializeDraft,
slugify,
Expand Down Expand Up @@ -163,6 +165,13 @@ describe("validation", () => {
validateSchool({ school: "A", subdomain: "abc" }, "taken").subdomain,
).toBeDefined();
expect(validateSchool({ school: "A", subdomain: "abc" }, "ok")).toEqual({});
expect(
validateSchool({ school: "paypal.com support", subdomain: "abc" }, "ok").school,
).toBe(ERROR_COPY.schoolLink);
expect(validateSchool({ school: "x".repeat(81), subdomain: "abc" }, "ok").school).toBe(
ERROR_COPY.schoolLong,
);
expect(validateSchool({ school: "St. Mary's Guitar School", subdomain: "abc" }, "ok")).toEqual({});
});

it("validates each offer form", () => {
Expand Down Expand Up @@ -348,3 +357,19 @@ describe("site domain", () => {
).toBe("courselit.app");
});
});

describe("resume summary", () => {
const env = { firstRun: false, hosted: true };
const plan = (trialEndsAt: string | null) =>
resumeRows("offer", env, emptyWizardData(), "Owner", trialEndsAt).find(
(row) => row.key === "plan",
)?.desc;

it("asks for a plan when the school has none", () => {
expect(plan(null)).toBe("Choose a CourseLit plan to go live");
});

it("names the end of a running trial instead", () => {
expect(plan("2026-11-28T12:00:00.000Z")).toStartWith("Free trial until");
});
});
Loading
Loading