Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
70 commits
Select commit Hold shift + click to select a range
95fe5f9
feat(schema): add forms table and update service bookings with childI…
Jxl-s Jun 5, 2026
76a67bb
feat(schema): enhance schema with new fields for users, services, and…
Jxl-s Jun 5, 2026
cacddff
feat(services): make coach selection mandatory for private lessons
maxiDeee Jun 6, 2026
4842a8a
fix(services): keep coach error showing on repeated private-lesson su…
maxiDeee Jun 6, 2026
03db0eb
feat(schema): rename extra questions to form questions and update rel…
Jxl-s Jun 8, 2026
4a2c6f9
feat(wp-block): add tabs, service detail, and register flow to mcld s…
thomasballarddev Jun 9, 2026
e54f4df
fix(wp-blocks): more descriptive api url for future dev use
thomasballarddev Jun 10, 2026
1c5d21d
Merge remote-tracking branch 'origin/main' into feature/82-make-coach…
maxiDeee Jun 14, 2026
ea9619e
feat(schema): add default to sortOrder, do first migration
Jxl-s Jun 14, 2026
cd42733
Merge remote-tracking branch 'origin/develop' into new-kids-adults-se…
martin0024 Jun 15, 2026
ab3a031
refactor(schema): remove sortOrder field from formQuestions and clean…
martin0024 Jun 15, 2026
c48b797
Merge pull request #96 from hack4impact/feature/82-make-coach-selecti…
maxiDeee Jun 15, 2026
4b09df9
feat(schema): add services table constraints and new snapshot for chi…
Jxl-s Jun 16, 2026
b3ee208
Merge branch 'develop' into new-kids-adults-services-schemas
Jxl-s Jun 16, 2026
e7b71ac
refactor(schema): replace ExtraQuestionType with FormQuestionType and…
Jxl-s Jun 16, 2026
3c28267
feat(services): add service registration views and queries for adults…
Jxl-s Jun 16, 2026
313afdb
feat(layout): wrap AppSidebar in Suspense for improved loading experi…
Jxl-s Jun 16, 2026
7728d25
feat(services): integrate forms into ServicesPage, ServiceDialog, and…
Jxl-s Jun 16, 2026
bfff02b
Merge pull request #93 from hack4impact/new-kids-adults-services-schemas
martin0024 Jun 16, 2026
818f2a3
refactor(checkout): remove checkout and scheduling actions, tests, an…
martin0024 Jun 16, 2026
893bf65
refactor(services): rename coach references to coordinator and update…
martin0024 Jun 16, 2026
dd9081e
refactor(emails): react-emails
martin0024 Jun 16, 2026
8e26850
feat(services): enhance ChildInfoModal with structured info rows and …
Jxl-s Jun 16, 2026
6c2ea1a
feat: implement Stripe transaction history and refund functionality f…
Berny-ft Jun 14, 2026
8cba3e0
feat(refund): enhance refund process with validation and user feedbac…
Berny-ft Jun 17, 2026
9c48996
refactor(transactions): simplify transaction modal by removing expand…
Berny-ft Jun 19, 2026
7d5710c
fix(transactions): improve error handling in transaction and refund p…
Berny-ft Jun 19, 2026
80bc4ec
fix(transactions): enhance pagination logic in user transactions modal
Berny-ft Jun 19, 2026
79866bb
refactor(transactions): remove endingBefore parameter from user trans…
Berny-ft Jun 19, 2026
ee0cbbb
feat(services): add requires_subscription field to service model and …
achneerov Jun 14, 2026
539d8f6
feat(services): integrate checkbox for requires_subscription in servi…
achneerov Jun 14, 2026
42aa389
feat(ui): add Checkbox component for enhanced form interactions
achneerov Jun 14, 2026
3a2e879
feat(services): include requiresSubscription in service response for …
achneerov Jun 14, 2026
9b77107
feat(services): add subscription badge to services data table and cre…
achneerov Jun 20, 2026
f86f049
feat(services): update foreign key constraints and add check constrai…
achneerov Jun 20, 2026
f778a69
feat(services): add unique index for service bookings and ensure priv…
achneerov Jun 20, 2026
1812cf3
test(services): add requires_subscription field to createService tests
achneerov Jun 20, 2026
04991b2
refactor(stripe): streamline price management by introducing deactiva…
achneerov Jun 6, 2026
515aafb
feat(stripe): implement replaceProductPrice function to manage defaul…
achneerov Jun 14, 2026
3f0b4c0
refactor(stripe): remove deprecated price management functions
achneerov Jun 20, 2026
f267df1
Merge pull request #108 from hack4impact/feature/102-coordinator-book…
RenaudBernier Jun 20, 2026
93973d0
refactor(users): remove status column from users table component
achneerov Jun 20, 2026
d7feaf2
Merge branch 'develop' into feature/81-set-stripe-default-price-on-se…
martin0024 Jun 21, 2026
aed71ee
Merge remote-tracking branch 'origin/develop' into feature/81-set-str…
martin0024 Jun 21, 2026
efb6917
refactor(tests): update service action tests to replace deprecated pr…
martin0024 Jun 21, 2026
4e77048
Merge remote-tracking branch 'origin/develop' into feature/80-link-se…
martin0024 Jun 21, 2026
e9cf78b
refactor(services): remove unused coachId
martin0024 Jun 21, 2026
a9d806e
Merge pull request #111 from hack4impact/feature/98-remove-the-status…
RenaudBernier Jun 21, 2026
930b588
Merge pull request #99 from hack4impact/feature/81-set-stripe-default…
RenaudBernier Jun 21, 2026
b4e3895
feat(refund): add customer ID validation to refund process and enhanc…
Berny-ft Jun 22, 2026
33cb81d
Merge pull request #104 from hack4impact/feature/89-user-transactions
martin0024 Jun 23, 2026
802ae65
feat(wp-blocks): changes according to review, color pallete updated
thomasballarddev Jun 23, 2026
78dbbf7
fix(error-msg): mattia is not cooked
thomasballarddev Jun 23, 2026
aba76e6
fix(services): center Suspense spinner in full-screen layout
Jxl-s Jun 27, 2026
8b34cdb
fix(services): replace native checkbox with shadcn Checkbox component
Jxl-s Jun 27, 2026
04b5b03
refactor(services): extract STATUS_VARIANT and GENDER_LABELS into sha…
Jxl-s Jun 27, 2026
2f3c245
fix(format): add formatDateFromInstant to avoid UTC off-by-one
Jxl-s Jun 27, 2026
cdd0edb
refactor(services): simplify AdultTable — use shared constants, fix d…
Jxl-s Jun 27, 2026
3e07dc8
feat(services): make table height responsive to window size
Jxl-s Jun 27, 2026
e94d5ce
fix(error-msg): throw more descriptive error msg for user facing error
thomasballarddev Jun 27, 2026
4354275
Merge pull request #105 from hack4impact/feature/80-link-sevices-to-a…
achneerov Jun 27, 2026
37f4e99
Merge pull request #100 from hack4impact/feature/70-wp-component-serv…
martin0024 Jun 28, 2026
98b4560
Merge remote-tracking branch 'origin/develop' into feature/87-per-ser…
martin0024 Jul 6, 2026
68fc410
feat(services): add column width metadata for table headers
Jxl-s Jul 9, 2026
45f80f5
Merge pull request #107 from hack4impact/feature/87-per-service-view
martin0024 Jul 9, 2026
f98abba
Merge remote-tracking branch 'origin/develop' into feature/97-service…
martin0024 Jul 9, 2026
f16818a
Merge pull request #115 from hack4impact/feature/97-services-table-he…
RenaudBernier Jul 22, 2026
a636020
feat(auth): implement user role-based access
martin0024 Aug 10, 2026
1539212
feat(account): add bookings, children, and transactions pages with co…
martin0024 Aug 10, 2026
0d79972
feat(profile): implement profile management with update functionality
martin0024 Aug 10, 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
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ STRIPE_PRICE_ID=
STRIPE_SECRET_KEY=
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=

WORDPRESS_API_URL=http://xxx.com
WORDPRESS_API_URL=https://example.com
WORDPRESS_USERNAME=mcld-dashboard
WORDPRESS_APP_PASSWORD="xxxx xxxx xxxx xxxx xxxx xxxx"
WORDPRESS_POST_TYPE=posts
Expand Down
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
/.next/
/out/

/.react-email

# production
/build

Expand Down Expand Up @@ -50,8 +52,6 @@ next-env.d.ts
*.swo
*~

# drizzle
drizzle/meta/

# supabase
.supabase/
Expand Down
224 changes: 224 additions & 0 deletions __tests__/services-actions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
/**
* @jest-environment node
*/
import {
createService,
updateService,
} from "@/app/(authenticated)/services/actions";

// db mock
const insertValues = jest.fn().mockResolvedValue(undefined);
const insert = jest.fn(() => ({ values: insertValues })) as jest.Mock;

const selectLimit = jest.fn();
const selectWhere = jest.fn(() => ({ limit: selectLimit }));
const selectFrom = jest.fn(() => ({ where: selectWhere }));
const select = jest.fn(() => ({ from: selectFrom })) as jest.Mock;

const updateWhere = jest.fn().mockResolvedValue(undefined);
const updateSet = jest.fn(() => ({ where: updateWhere }));
const update = jest.fn(() => ({ set: updateSet })) as jest.Mock;

jest.mock("@/lib/db", () => ({
db: {
insert: (...args: unknown[]) => insert(...args),
select: (...args: unknown[]) => select(...args),
update: (...args: unknown[]) => update(...args),
},
}));

// stripe mock
const createProduct = jest.fn();
const createPrice = jest.fn();
const updateProduct = jest.fn();
const replaceProductPrice = jest.fn();
const getStripeServiceData = jest.fn();

jest.mock("@/lib/stripe", () => ({
createProduct: (...args: unknown[]) => createProduct(...args),
createPrice: (...args: unknown[]) => createPrice(...args),
updateProduct: (...args: unknown[]) => updateProduct(...args),
replaceProductPrice: (...args: unknown[]) => replaceProductPrice(...args),
getStripeServiceData: (...args: unknown[]) => getStripeServiceData(...args),
}));

// auth + cache mocks
const requireAdmin = jest.fn();
jest.mock("@/lib/auth/require-admin", () => ({
requireAdmin: (...args: unknown[]) => requireAdmin(...args),
}));

jest.mock("next/cache", () => ({
revalidatePath: jest.fn(),
updateTag: jest.fn(),
}));

const COORDINATOR_A = "11111111-1111-1111-1111-111111111111";
const COORDINATOR_B = "22222222-2222-2222-2222-222222222222";
const SERVICE_ID = "33333333-3333-3333-3333-333333333333";

function fd(obj: Record<string, string>): FormData {
const f = new FormData();
for (const [k, v] of Object.entries(obj)) f.append(k, v);
return f;
}

beforeEach(() => {
jest.clearAllMocks();
requireAdmin.mockResolvedValue(undefined);
createProduct.mockResolvedValue({ productId: "prod_1" });
createPrice.mockResolvedValue(undefined);
updateProduct.mockResolvedValue(undefined);
replaceProductPrice.mockResolvedValue({ priceId: "price_new" });
getStripeServiceData.mockResolvedValue({ priceCents: 5000 });
});

describe("createService", () => {
it("rejects a private lesson without a coordinator", async () => {
const result = await createService(
null,
fd({
title: "1:1 Lesson",
description: "A private session",
type: "private_lessons",
duration_minutes: "60",
price_cad: "50.00",
}),
);

expect(result?.errors?.coordinator_id).toBeDefined();
expect(insert).not.toHaveBeenCalled();
expect(createProduct).not.toHaveBeenCalled();
});

it("persists the coordinator when creating a private lesson", async () => {
const result = await createService(
null,
fd({
title: "1:1 Lesson",
description: "A private session",
type: "private_lessons",
duration_minutes: "60",
price_cad: "50.00",
requires_subscription: "true",
coordinator_id: COORDINATOR_A,
}),
);

expect(result).toEqual({ message: "Service created." });
expect(insertValues).toHaveBeenCalledWith(
expect.objectContaining({
type: "private_lessons",
coordinatorId: COORDINATOR_A,
}),
);
});

it("creates a program with no coordinator", async () => {
const result = await createService(
null,
fd({
title: "Summer Program",
description: "Group program",
type: "programs",
duration_minutes: "60",
price_cad: "100.00",
start_date: "2026-01-01",
end_date: "2026-02-01",
slots: JSON.stringify([{ dayOfWeek: 1, time: "10:00" }]),
requires_subscription: "true",
}),
);

expect(result).toEqual({ message: "Service created." });
expect(insertValues).toHaveBeenCalledWith(
expect.objectContaining({ type: "programs", coordinatorId: null }),
);
});
});

describe("updateService", () => {
it("reassigns the coordinator on a private lesson", async () => {
selectLimit.mockResolvedValue([
{
id: SERVICE_ID,
type: "private_lessons",
status: "active",
stripeProductId: "prod_1",
},
]);

const result = await updateService(
null,
fd({ service_id: SERVICE_ID, coordinator_id: COORDINATOR_B }),
);

expect(result).toEqual({ message: "Service updated." });
expect(updateSet).toHaveBeenCalledWith(
expect.objectContaining({ coordinatorId: COORDINATOR_B }),
);
});

it("does not recreate the Stripe price when the amount is unchanged", async () => {
selectLimit.mockResolvedValue([
{
id: SERVICE_ID,
type: "private_lessons",
status: "active",
stripeProductId: "prod_1",
},
]);
getStripeServiceData.mockResolvedValue({ priceCents: 5000 });

const result = await updateService(
null,
fd({
service_id: SERVICE_ID,
coordinator_id: COORDINATOR_B,
price_cad: "50.00",
}),
);

expect(result).toEqual({ message: "Service updated." });
expect(replaceProductPrice).not.toHaveBeenCalled();
});

it("recreates the Stripe price when the amount changes", async () => {
selectLimit.mockResolvedValue([
{
id: SERVICE_ID,
type: "private_lessons",
status: "active",
stripeProductId: "prod_1",
},
]);
getStripeServiceData.mockResolvedValue({ priceCents: 5000 });

const result = await updateService(
null,
fd({ service_id: SERVICE_ID, price_cad: "75.00" }),
);

expect(result).toEqual({ message: "Service updated." });
expect(replaceProductPrice).toHaveBeenCalledWith("prod_1", 7500);
});

it("rejects clearing the coordinator on a private lesson", async () => {
selectLimit.mockResolvedValue([
{
id: SERVICE_ID,
type: "private_lessons",
status: "active",
stripeProductId: "prod_1",
},
]);

const result = await updateService(
null,
fd({ service_id: SERVICE_ID, coordinator_id: "" }),
);

expect(result?.errors?.coordinator_id).toBeDefined();
expect(updateSet).not.toHaveBeenCalled();
});
});
90 changes: 90 additions & 0 deletions __tests__/users-actions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* @jest-environment node
*/
import { deleteUserAdmin } from "@/app/(authenticated)/users/actions";

// db mock
const selectLimit = jest.fn();
const selectWhere = jest.fn(() => ({ limit: selectLimit }));
const selectFrom = jest.fn(() => ({ where: selectWhere }));
const select = jest.fn(() => ({ from: selectFrom })) as jest.Mock;

jest.mock("@/lib/db", () => ({
db: {
select: (...args: unknown[]) => select(...args),
},
}));

// supabase admin mock
const deleteUser = jest.fn();
jest.mock("@/utils/supabase/admin", () => ({
createAdminClient: () => ({
auth: { admin: { deleteUser: (...args: unknown[]) => deleteUser(...args) } },
}),
}));

// auth + cache + stripe mocks
const requireAdmin = jest.fn();
jest.mock("@/lib/auth/require-admin", () => ({
requireAdmin: (...args: unknown[]) => requireAdmin(...args),
}));

jest.mock("next/cache", () => ({ revalidatePath: jest.fn() }));

// actions.ts imports grantComplimentarySubscription from @/lib/stripe, which
// instantiates the Stripe client at module load.
jest.mock("@/lib/stripe", () => ({
grantComplimentarySubscription: jest.fn(),
}));

const COORDINATOR_ID = "11111111-1111-1111-1111-111111111111";

function fd(obj: Record<string, string>): FormData {
const f = new FormData();
for (const [k, v] of Object.entries(obj)) f.append(k, v);
return f;
}

beforeEach(() => {
jest.clearAllMocks();
requireAdmin.mockResolvedValue(undefined);
selectLimit.mockResolvedValue([]);
deleteUser.mockResolvedValue({ error: null });
});

describe("deleteUserAdmin", () => {
it("returns Unauthorized when the caller is not an admin", async () => {
requireAdmin.mockRejectedValue(new Error("Forbidden"));

const result = await deleteUserAdmin(null, fd({ user_id: COORDINATOR_ID }));

expect(result).toEqual({ errors: { _form: ["Unauthorized"] } });
expect(deleteUser).not.toHaveBeenCalled();
});

it("blocks deletion when the coordinator is assigned to a private lesson", async () => {
selectLimit.mockResolvedValue([{ id: "svc-1" }]);

const result = await deleteUserAdmin(null, fd({ user_id: COORDINATOR_ID }));

expect(result?.errors?._form?.[0]).toMatch(/private lesson/i);
expect(deleteUser).not.toHaveBeenCalled();
});

it("deletes the user when not assigned to any private lesson", async () => {
selectLimit.mockResolvedValue([]);

const result = await deleteUserAdmin(null, fd({ user_id: COORDINATOR_ID }));

expect(result).toEqual({ message: "User deleted." });
expect(deleteUser).toHaveBeenCalledWith(COORDINATOR_ID);
});

it("surfaces an auth error from Supabase", async () => {
deleteUser.mockResolvedValue({ error: { message: "boom" } });

const result = await deleteUserAdmin(null, fd({ user_id: COORDINATOR_ID }));

expect(result).toEqual({ errors: { _form: ["boom"] } });
});
});
21 changes: 21 additions & 0 deletions app/(authenticated)/account/_components/status-badge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Badge } from "@/components/ui/badge";

const STATUS_CLASS: Record<string, string> = {
confirmed: "bg-green-700/80 text-white",
completed: "bg-green-700/80 text-white",
pending: "bg-amber-700/80 text-white",
awaiting_payment: "bg-amber-700/80 text-white",
cancelled: "bg-muted text-muted-foreground",
};

export function StatusBadge({ status }: { status: string }) {
return (
<Badge
className={
STATUS_CLASS[status] ?? "bg-secondary/80 text-secondary-foreground"
}
>
{status.replace(/_/g, " ")}
</Badge>
);
}
Loading
Loading