{user && (
diff --git a/schema/src/client.ts b/schema/src/client.ts
index 701b82a..bd833f2 100644
--- a/schema/src/client.ts
+++ b/schema/src/client.ts
@@ -4,31 +4,34 @@ import {
type CreateAuditResponse,
type CreateItemResponse,
type CreateLocationResponse,
- type CreateUserResponse,
- type DeleteUserResponse,
type DeleteItemResponse,
- type AuthenticateUserPayload,
- type AuthenticateUserResponse,
type InventoryLotDraft,
type InventoryLotResource,
type ItemDraft,
type ItemResource,
type ListItemsResponse,
type ListLocationsResponse,
- type ListUsersResponse,
type LocationDraft,
type LocationResource,
type RecordStockTransactionResponse,
type StockTransactionDraft,
type StockTransactionResource,
type UpdateItemResponse,
- type UpdateUserResponse,
type UpsertInventoryLotResponse,
- type UserDraft,
- type UserResource,
type ApiErrorPayload,
} from "./types";
+import {
+ type AuthenticateUserPayload,
+ type AuthenticateUserResponse,
+ type CreateUserResponse,
+ type DeleteUserResponse,
+ type ListUsersResponse,
+ type UpdateUserResponse,
+ type UserDraft,
+ type UserResource,
+} from "./user";
+
type FetchLike = typeof fetch;
export interface ClientInit {
diff --git a/schema/src/index.ts b/schema/src/index.ts
index c68a033..0dbed02 100644
--- a/schema/src/index.ts
+++ b/schema/src/index.ts
@@ -5,4 +5,6 @@ export {
type SchemaClient,
} from "./client";
+export { toRole } from "./user";
export type * from "./types";
+export type * from "./user";
diff --git a/schema/src/types.ts b/schema/src/types.ts
index 1447167..7a9410f 100644
--- a/schema/src/types.ts
+++ b/schema/src/types.ts
@@ -182,30 +182,3 @@ export interface AuditResource {
}
export type CreateAuditResponse = ApiResponse<{ audit: AuditResource }>;
-
-export interface UserDraft {
- email: string;
- name: string;
- password: string;
- role?: "admin" | "staff" | "volunteer";
-}
-
-export interface UserResource {
- _id: ObjectIdString;
- email: string;
- name: string;
- role: ["admin" | "staff" | "volunteer"];
- enabled: boolean;
- createdAt: ISODateString;
-}
-
-export interface AuthenticateUserPayload {
- email: string;
- password: string;
-}
-
-export type AuthenticateUserResponse = ApiResponse<{ user: UserResource }>;
-export type CreateUserResponse = ApiResponse<{ user: UserResource }>;
-export type UpdateUserResponse = ApiResponse<{ user: UserResource }>;
-export type DeleteUserResponse = ApiResponse<{ deleted: boolean }>;
-export type ListUsersResponse = ApiResponse<{ users: UserResource[] }>;
diff --git a/schema/src/user.ts b/schema/src/user.ts
new file mode 100644
index 0000000..d9cc33d
--- /dev/null
+++ b/schema/src/user.ts
@@ -0,0 +1,37 @@
+import type { ApiResponse, ISODateString, ObjectIdString } from "./types";
+
+const allowedRoles = ["admin", "staff", "volunteer"] as const;
+export type Role = (typeof allowedRoles)[number];
+
+export function toRole(value: unknown): Role {
+ return typeof value === "string" && allowedRoles.includes(value as Role)
+ ? (value as Role)
+ : "volunteer";
+}
+
+export interface UserDraft {
+ email: string;
+ name: string;
+ password: string;
+ role?: Role;
+}
+
+export interface UserResource {
+ _id: ObjectIdString;
+ email: string;
+ name: string;
+ role: Role;
+ enabled: boolean;
+ createdAt: ISODateString;
+}
+
+export interface AuthenticateUserPayload {
+ email: string;
+ password: string;
+}
+
+export type AuthenticateUserResponse = ApiResponse<{ user: UserResource }>;
+export type CreateUserResponse = ApiResponse<{ user: UserResource }>;
+export type UpdateUserResponse = ApiResponse<{ user: UserResource }>;
+export type DeleteUserResponse = ApiResponse<{ deleted: boolean }>;
+export type ListUsersResponse = ApiResponse<{ users: UserResource[] }>;
\ No newline at end of file
diff --git a/server/postman/backend.postman_collection.json b/server/postman/backend.postman_collection.json
index 4132499..1d3bf52 100644
--- a/server/postman/backend.postman_collection.json
+++ b/server/postman/backend.postman_collection.json
@@ -415,7 +415,7 @@
"const json = pm.response.json();",
"pm.test('user id unchanged', () => pm.expect(json.user._id).to.eql(pm.collectionVariables.get('userId')));",
"pm.test('name updated', () => pm.expect(json.user.name).to.eql(pm.collectionVariables.get('updatedUserName')));",
- "pm.test('role is admin', () => pm.expect(json.user.role).to.include('admin'));"
+ "pm.test('role is admin', () => pm.expect(json.user.role).to.eql('admin'));"
]
}
}
diff --git a/server/src/server.ts b/server/src/server.ts
index 2130e59..0cf009c 100644
--- a/server/src/server.ts
+++ b/server/src/server.ts
@@ -17,7 +17,7 @@ for (const candidate of envPaths) {
if (fs.existsSync(candidate)) {
dotenv.config({
path: candidate,
- override: true,
+ override: false,
});
break;
}
diff --git a/server/src/user.ts b/server/src/user.ts
index fcb1e2e..8a9f315 100644
--- a/server/src/user.ts
+++ b/server/src/user.ts
@@ -9,10 +9,9 @@ import {
sanitizeObjectId,
} from "./validation.js";
import { handleDatabaseError, sendError, sendSuccess } from "./responses.js";
-import type { UserResource } from "@foodstoragemanager/schema";
+import { toRole, type Role, type UserResource } from "@foodstoragemanager/schema";
const COLLECTION = "users";
-const ALLOWED_ROLES = new Set(["admin", "staff", "volunteer"]);
function formatNow(): string {
return new Date().toISOString();
@@ -26,34 +25,28 @@ function toIsoString(value: unknown): string | undefined {
} else return undefined;
}
-function sanitizeRoles(value: unknown): string | undefined {
- if (Array.isArray(value) && value.length > 0) {
- const role = typeof value[0] === "string" ? value[0] : undefined;
- const normalized = role?.trim().toLowerCase();
- return normalized && ALLOWED_ROLES.has(normalized) ? normalized : undefined;
- }
-
- if (typeof value === "string") {
- const normalized = value.trim().toLowerCase();
- return ALLOWED_ROLES.has(normalized) ? normalized : undefined;
- }
+const ensureRole = (value: unknown): Role => {
+ const candidate = Array.isArray(value) && value.length > 0 ? value[0] : value;
+ const normalizedCandidate =
+ typeof candidate === "string" ? candidate.trim().toLowerCase() : candidate;
- return undefined;
-}
+ // toRole will coerce/validate and fall back to "volunteer" for anything invalid.
+ return toRole(normalizedCandidate);
+};
-function serializeUser(doc: Record): UserResource {
- const role = sanitizeRoles(doc.roles ?? doc.role) ?? "volunteer";
+const serializeUser = (doc: Record): UserResource => {
+ const role = ensureRole(doc.roles ?? doc.role);
const createdAt: string = toIsoString(doc.createdAt) ?? formatNow();
return {
_id: String(doc._id),
email: String(doc.email),
name: doc.name ? String(doc.name) : "",
- role: [role as "admin" | "staff" | "volunteer"],
+ role,
enabled: typeof doc.enabled === "boolean" ? doc.enabled : true,
createdAt: createdAt,
};
-}
+};
async function getUsers(db: Connection): Promise {
const collection = db.collection(COLLECTION);
@@ -121,9 +114,10 @@ export const createUser: ApiHandler = async (req, res, db) => {
const enabled = typeof draft.enabled === "boolean" ? draft.enabled : true;
const now = new Date();
+ const normalizedRole = ensureRole(role);
document = {
email,
- roles: role && ALLOWED_ROLES.has(role) ? [role] : ["volunteer"],
+ role: normalizedRole,
enabled,
createdAt: now,
updatedAt: now,
@@ -219,34 +213,27 @@ export const updateUser: ApiHandler = async (req, res, db) => {
}
const draft = payload.user as Record;
- const update: Record = {};
+ const updateSet: Record = {};
+ const updateUnset: Record = {};
try {
if (draft.name !== undefined) {
const name = sanitizeString(draft.name, "user.name");
- update.name = name || null;
+ updateSet.name = name || null;
}
if (draft.role !== undefined) {
- // Convert single role to roles array for database
- const role =
- typeof draft.role === "string"
- ? draft.role.trim().toLowerCase()
- : undefined;
-
- if (role && ALLOWED_ROLES.has(role)) {
- update.roles = [role];
- } else if (role === null || role === undefined || role === "") {
- update.roles = [];
- } else {
- throw new Error(
- `Invalid role: ${role}. Allowed roles are: admin, staff, volunteer.`
- );
+ const roleValue = sanitizeString(draft.role, "user.role", {
+ lowercase: true,
+ });
+ if (roleValue !== undefined) {
+ updateSet.role = ensureRole(roleValue);
+ updateUnset.roles = "";
}
}
if (draft.enabled !== undefined) {
- update.enabled =
+ updateSet.enabled =
typeof draft.enabled === "boolean" ? draft.enabled : true;
}
} catch (error) {
@@ -263,7 +250,18 @@ export const updateUser: ApiHandler = async (req, res, db) => {
return sendError(res, StatusCodes.NOT_FOUND, "User not found.");
}
- await collection.updateOne({ _id: userId }, { $set: update });
+ // Ensure the stored role complies with the new schema and migrate any legacy "roles" arrays.
+ updateSet.role = ensureRole(
+ updateSet.role ?? (existing as Record).role ?? (existing as Record).roles
+ );
+ updateUnset.roles = "";
+
+ const updateOps: Record = { $set: updateSet };
+ if (Object.keys(updateUnset).length > 0) {
+ updateOps.$unset = updateUnset;
+ }
+
+ await collection.updateOne({ _id: userId }, updateOps);
const updated = await collection.findOne({ _id: userId });
if (!updated) {