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
131 changes: 131 additions & 0 deletions app-backend/src/controllers/availabilitySlot.controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/**
* controllers/availabilitySlot.controller.js
*
* Thin HTTP layer for calendar-based availability slots (Ticket #20).
* All business logic lives in services/availabilitySlot.service.js. The guard
* identity is always taken from the authenticated user (req.user), never from
* the request body, so guards can only ever act on their own slots.
*
* Response shapes match the Guard App contract (confirmed with Krisha Patel):
* - create : { message, availability: <slot> }
* - list : { availability: [<slot>, ...] }
* - delete : { message }
* - clear : { message, deletedCount }
*/

import { ACTIONS } from "../middleware/logger.js";
import * as slotService from "../services/availabilitySlot.service.js";

/**
* Resolve the authenticated guard id, or send 401 and return null.
*/
const requireGuardId = (req, res) => {
const guardId = req.user?.id || req.user?._id;
if (!guardId) {
res.status(401).json({ message: "Unauthorized" });
return null;
}
return guardId;
};

/**
* Map a thrown service error to its HTTP response. Errors raised by the service
* carry a `.status`; anything else is an unexpected 500.
*/
const handleError = (res, err, context) => {
if (err?.status) {
return res.status(err.status).json({ message: err.message });
}
console.error(`${context}:`, err);
return res.status(500).json({ message: "Server error", error: err.message });
};

/**
* POST /api/v1/availability/slots
*/
export const createSlot = async (req, res) => {
const guardId = requireGuardId(req, res);
if (!guardId) return;

try {
const slot = await slotService.createSlot(guardId, req.body);

if (req.audit?.log) {
await req.audit.log(guardId, ACTIONS.AVAILABILITY_SLOT_CREATED, {
slotId: slot._id,
date: slot.date,
});
}

return res.status(201).json({
message: "Availability slot created successfully.",
availability: slot,
});
} catch (err) {
return handleError(res, err, "AvailabilitySlot CREATE error");
}
};

/**
* GET /api/v1/availability/slots/my-slots
*/
export const getMySlots = async (req, res) => {
const guardId = requireGuardId(req, res);
if (!guardId) return;

try {
const slots = await slotService.listMySlots(guardId, req.query);
return res.status(200).json({ availability: slots });
} catch (err) {
return handleError(res, err, "AvailabilitySlot LIST error");
}
};

/**
* DELETE /api/v1/availability/slots/:id
*/
export const deleteSlot = async (req, res) => {
const guardId = requireGuardId(req, res);
if (!guardId) return;

try {
const slot = await slotService.deleteSlot(guardId, req.params.id);

if (req.audit?.log) {
await req.audit.log(guardId, ACTIONS.AVAILABILITY_SLOT_DELETED, {
slotId: slot._id,
});
}

return res
.status(200)
.json({ message: "Availability slot deleted successfully." });
} catch (err) {
return handleError(res, err, "AvailabilitySlot DELETE error");
}
};

/**
* DELETE /api/v1/availability/slots/clear-all
*/
export const clearAllSlots = async (req, res) => {
const guardId = requireGuardId(req, res);
if (!guardId) return;

try {
const deletedCount = await slotService.clearAllSlots(guardId);

if (req.audit?.log) {
await req.audit.log(guardId, ACTIONS.AVAILABILITY_SLOTS_CLEARED, {
deletedCount,
});
}

return res.status(200).json({
message: "All availability slots cleared successfully.",
deletedCount,
});
} catch (err) {
return handleError(res, err, "AvailabilitySlot CLEAR error");
}
};
3 changes: 3 additions & 0 deletions app-backend/src/middleware/logger.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ export const ACTIONS = {

USER_SOFT_DELETED: "USER_SOFT_DELETED",
AVAILABILITY_UPDATED: "AVAILABILITY_UPDATED",
AVAILABILITY_SLOT_CREATED: "AVAILABILITY_SLOT_CREATED",
AVAILABILITY_SLOT_DELETED: "AVAILABILITY_SLOT_DELETED",
AVAILABILITY_SLOTS_CLEARED: "AVAILABILITY_SLOTS_CLEARED",
RATINGS_SUBMITTED: "RATINGS_SUBMITTED",

SITE_CREATED: "SITE_CREATED",
Expand Down
91 changes: 91 additions & 0 deletions app-backend/src/models/AvailabilitySlot.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* models/AvailabilitySlot.js
*
* Calendar-based availability slots for the Guard App (Ticket #20).
*
* This is a DISTINCT concept from models/Availability.js. `Availability` is a
* single unique document per user describing recurring weekday preferences
* (days[] / timeSlots[] / live status). An `AvailabilitySlot` is one concrete
* date-bound window a guard is available; a guard owns MANY of them.
*
* Slot shape matches the Guard App contract (guard_app/src/api/availability.ts,
* AvailabilitySlotDto):
* { _id, guardId, date, fromTime, toTime, recurring?, createdAt, updatedAt }
*/

import mongoose from "mongoose";

const { Schema } = mongoose;

// "YYYY-MM-DD" zero-padded ISO calendar date. Sorts and range-filters lexically.
export const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;

// "HH:MM" 24-hour clock.
export const TIME_REGEX = /^([01]\d|2[0-3]):[0-5]\d$/;

export const RECURRING_PATTERNS = ["weekly", "daily"];

const RecurringSchema = new Schema(
{
enabled: {
type: Boolean,
default: false,
},
pattern: {
type: String,
enum: RECURRING_PATTERNS,
},
endDate: {
type: String,
match: ISO_DATE_REGEX,
},
},
{ _id: false },
);

const AvailabilitySlotSchema = new Schema(
{
guardId: {
type: Schema.Types.ObjectId,
ref: "User",
required: true,
index: true,
},

date: {
type: String,
required: true,
match: [ISO_DATE_REGEX, 'date must be in "YYYY-MM-DD" format.'],
},

fromTime: {
type: String,
required: true,
match: [TIME_REGEX, 'fromTime must be in "HH:MM" 24-hour format.'],
},

toTime: {
type: String,
required: true,
match: [TIME_REGEX, 'toTime must be in "HH:MM" 24-hour format.'],
},

recurring: {
type: RecurringSchema,
default: undefined,
},
},
{
timestamps: true,
},
);

// Common access pattern: a guard's slots ordered along the calendar.
AvailabilitySlotSchema.index({ guardId: 1, date: 1, fromTime: 1 });

const AvailabilitySlot = mongoose.model(
"AvailabilitySlot",
AvailabilitySlotSchema,
);

export default AvailabilitySlot;
Loading
Loading