Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions apps/api/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "f3-nation-api",
"version": "3.9.0",
"version": "3.9.1",
"private": true,
"type": "module",
"scripts": {
Expand All @@ -21,7 +21,7 @@
"with-env": "dotenv -e ../../.env --"
},
"dependencies": {
"@acme/api": "workspace:*",
"@acme/api": "workspace:^0.2.0",
"@acme/auth": "workspace:^0.1.0",
"@acme/db": "workspace:^0.1.0",
"@acme/env": "workspace:^0.1.0",
Expand Down
4 changes: 2 additions & 2 deletions apps/map/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "f3-nation-map",
"version": "3.9.0",
"version": "3.9.1",
"private": true,
"type": "module",
"scripts": {
Expand All @@ -21,7 +21,7 @@
"with-env": "dotenv -e ../../.env --"
},
"dependencies": {
"@acme/api": "workspace:*",
"@acme/api": "workspace:^0.2.0",
"@acme/auth": "workspace:^0.1.0",
"@acme/db": "workspace:^0.1.0",
"@acme/env": "workspace:^0.1.0",
Expand Down
17 changes: 17 additions & 0 deletions apps/map/src/app/_components/modal/admin-aos-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,23 @@ export default function AdminAOsModal({
schema: AOInsertSchema.extend({
badImage: z.boolean().default(false),
}),
defaultValues: {
id: ao?.id ?? undefined,
name: ao?.name ?? "",
parentId: ao?.parentId ?? -1,
defaultLocationId: ao?.defaultLocationId ?? null,
isActive: ao?.isActive ?? true,
description: ao?.description ?? "",
logoUrl: ao?.logoUrl ?? null,
website: ao?.website ?? null,
email: ao?.email ?? null,
twitter: ao?.twitter ?? null,
facebook: ao?.facebook ?? null,
instagram: ao?.instagram ?? null,
lastAnnualReview: ao?.lastAnnualReview ?? null,
meta: ao?.meta ?? null,
badImage: false,
},
});

useEffect(() => {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "f3-nation",
"version": "3.9.0",
"version": "3.9.1",
"private": true,
"engines": {
"node": ">=20.19.0 <21"
Expand Down
13 changes: 13 additions & 0 deletions packages/api/src/router/event.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,19 @@ describe("Event Router", () => {
});

it("should return response shape without pre-existing data", async () => {
const client = createTestClient();
const result = await client.map.event.all({
pageIndex: 0,
pageSize: 50,
statuses: ["active"],
});

expect(result.events?.length).toBeGreaterThan(0);
});
});

describe("map.event.all", () => {
it("should return a list of events with filtering", async () => {
const client = createTestClient();
const result = await client.map.event.all({
pageIndex: 0,
Expand Down
130 changes: 120 additions & 10 deletions packages/api/src/router/user.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -868,7 +868,7 @@ describe("User Router", () => {
).rejects.toThrow("already exists");
});

it("should reject update when admin does not manage user's home region", async () => {
it("should skip profile update but allow role changes when admin does not manage user's home region", async () => {
const dbInstance = db;

// Create two region orgs
Expand Down Expand Up @@ -925,17 +925,127 @@ describe("User Router", () => {

const client = createTestClient();

await expect(
client.user.crupdate({
id: testUser.id,
f3Name: "Updated",
roles: [],
}),
).rejects.toThrow(
"You can only modify users whose home region you manage",
);
// Should succeed — profile data is skipped, roles are processed
const result = await client.user.crupdate({
id: testUser.id,
f3Name: "Updated",
roles: [{ orgId: regionA.id, roleName: "admin" }],
});

// Profile data should NOT have been modified
expect(result.id).toBe(testUser.id);
expect(result.f3Name).toBe("HomeRegionTest");

// Role on regionA should have been granted
const grantedRoles = await dbInstance
.select()
.from(schema.rolesXUsersXOrg)
.where(eq(schema.rolesXUsersXOrg.userId, testUser.id));
expect(grantedRoles.some((r) => r.orgId === regionA.id)).toBe(true);

// Clean up
await dbInstance
.delete(schema.rolesXUsersXOrg)
.where(eq(schema.rolesXUsersXOrg.userId, testUser.id));
await dbInstance
.delete(schema.users)
.where(eq(schema.users.id, testUser.id));
await dbInstance
.delete(schema.orgs)
.where(eq(schema.orgs.id, regionA.id));
await dbInstance
.delete(schema.orgs)
.where(eq(schema.orgs.id, regionB.id));
});

it("should not modify profile fields when admin does not manage user's home region", async () => {
const dbInstance = db;

const [regionA] = await dbInstance
.insert(schema.orgs)
.values({
name: `RegionA-${Date.now()}`,
orgType: "region",
isActive: true,
})
.returning();
const [regionB] = await dbInstance
.insert(schema.orgs)
.values({
name: `RegionB-${Date.now()}`,
orgType: "region",
isActive: true,
})
.returning();

if (!regionA || !regionB)
throw new Error("Failed to create test regions");

const originalF3Name = "OriginalName";
const originalFirstName = "OriginalFirst";
const [testUser] = await dbInstance
.insert(schema.users)
.values({
email: `profile-guard-${Date.now()}@example.com`,
f3Name: originalF3Name,
firstName: originalFirstName,
homeRegionId: regionB.id,
})
.returning();

if (!testUser) throw new Error("Failed to create test user");

const mockSession: Session = {
id: 1,
email: "admin-a@example.com",
user: {
id: "1",
email: "admin-a@example.com",
name: "AdminA",
roles: [
{ orgId: regionA.id, orgName: regionA.name, roleName: "admin" },
],
},
roles: [
{ orgId: regionA.id, orgName: regionA.name, roleName: "admin" },
],
expires: new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString(),
};
await mockAuthWithSession(mockSession);

const client = createTestClient();

// Attempt to modify profile fields and add a role on regionA
const result = await client.user.crupdate({
id: testUser.id,
f3Name: "AttemptedChange",
firstName: "AttemptedFirst",
roles: [{ orgId: regionA.id, roleName: "editor" }],
});

// Profile fields must be unchanged
expect(result.f3Name).toBe(originalF3Name);
expect(result.firstName).toBe(originalFirstName);

// Verify directly in DB that profile wasn't modified
const [dbUser] = await dbInstance
.select()
.from(schema.users)
.where(eq(schema.users.id, testUser.id));
expect(dbUser?.f3Name).toBe(originalF3Name);
expect(dbUser?.firstName).toBe(originalFirstName);

// Role on regionA should have been granted
const grantedRoles = await dbInstance
.select()
.from(schema.rolesXUsersXOrg)
.where(eq(schema.rolesXUsersXOrg.userId, testUser.id));
expect(grantedRoles.some((r) => r.orgId === regionA.id)).toBe(true);

// Clean up
await dbInstance
.delete(schema.rolesXUsersXOrg)
.where(eq(schema.rolesXUsersXOrg.userId, testUser.id));
await dbInstance
.delete(schema.users)
.where(eq(schema.users.id, testUser.id));
Expand Down
63 changes: 37 additions & 26 deletions packages/api/src/router/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ export const userRouter = {
const { roles: rawRoles, ...rest } = input;
const roles = rawRoles as RoleInput[];

// Enforce home-region authorization for existing users
let canEditProfile = true;
if (input.id && ctx.session?.id !== input.id) {
const [existingUser] = await ctx.db
.select({ homeRegionId: schema.users.homeRegionId })
Expand All @@ -307,9 +307,7 @@ export const userRouter = {
roleName: "editor",
});
if (!success) {
throw new ORPCError("UNAUTHORIZED", {
message: "You can only modify users whose home region you manage",
});
canEditProfile = false;
}
}
}
Expand Down Expand Up @@ -409,32 +407,45 @@ export const userRouter = {
console.log("Update set", JSON.stringify(updateSet));

let user: typeof schema.users.$inferSelect;
try {
const result = await ctx.db
.insert(schema.users)
.values({
...rest,
email: normalizedEmail ?? "", // Ensure required email is not undefined
})
.onConflictDoUpdate({
target: [schema.users.id],
set: updateSet,
})
.returning();

const insertedUser = result[0];
if (!insertedUser) {
if (input.id && !canEditProfile) {
// Cannot edit profile data but can still manage roles.
// Just fetch the existing user without modifying profile fields.
const [existingUser] = await ctx.db
.select()
.from(schema.users)
.where(eq(schema.users.id, input.id));
if (!existingUser) {
throw new Error("User not found");
}
user = insertedUser;
} catch (error) {
if (isDuplicateEmailError(error)) {
throw new ORPCError("BAD_REQUEST", {
message: `A user with the email address "${_email ?? ""}" already exists. Please use a different email address.`,
});
user = existingUser;
} else {
try {
const result = await ctx.db
.insert(schema.users)
.values({
...rest,
email: normalizedEmail ?? "",
})
.onConflictDoUpdate({
target: [schema.users.id],
set: updateSet,
})
.returning();

const insertedUser = result[0];
if (!insertedUser) {
throw new Error("User not found");
}
user = insertedUser;
} catch (error) {
if (isDuplicateEmailError(error)) {
throw new ORPCError("BAD_REQUEST", {
message: `A user with the email address "${_email ?? ""}" already exists. Please use a different email address.`,
});
}
throw error;
}
// Re-throw other errors
throw error;
}

console.log("User", JSON.stringify(user));
Expand Down
29 changes: 29 additions & 0 deletions packages/shared/src/app/changelog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,35 @@ export interface ChangelogEntry {
}

export const changelog: ChangelogEntry[] = [
{
version: "3.9.1",
date: "2026-03-26",
title: "Map Updates & Admin Portal Improvements",
sections: [
{
title: "Map Updates",
items: [
"Map now refreshes automatically when you add, edit, or approve workout changes - no manual refresh or revalidation needed",
],
},
{
title: "Admin Portal",
items: [
"Bug Fix: Higher level admins can again grant access to lower orgs they manage.",
"Feature: User pages now show Home Region",
"Enhancement: Better experience when using the built-in map feature when editing a Location.",
"Enhancement: Social media fields now require http or https in the field. This ensure people enter full URLs and not handles.",
],
},
{
title: "API",
items: [
"Feature: /docs now displays a sample response for each endpoint, making it easier to understand the data structure and test API calls.",
"Feature: /user/byEmail now returns more information on a user.",
],
},
],
},
{
version: "3.8.0",
date: "2026-03-06",
Expand Down
Loading
Loading