Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
32269ff
chore(deps): update dependency firebase to v12.4.0 (#345)
renovate[bot] Oct 9, 2025
822975b
removed indexes from migration file
joeboppell Oct 9, 2025
b1fd282
change location role to none for reservations
joeboppell Oct 9, 2025
fbbc29b
fuck this relationship on the DB
kensac Oct 9, 2025
4ff2246
Format
kensac Oct 9, 2025
a9d9c10
chore(deps): update dependency @types/node to v22.18.9 (#346)
renovate[bot] Oct 10, 2025
89b5aa0
chore(deps): update dependency @nestjs/schematics to v11.0.9 (#348)
renovate[bot] Oct 10, 2025
4a23786
chore(deps): update dependency ts-jest to v29.4.5 (#349)
renovate[bot] Oct 11, 2025
c2fcb9a
chore(deps): update dependency @types/node to v22.18.10 (#350)
renovate[bot] Oct 11, 2025
ba39972
Implement endpoint for new inventory item patch
CruidGals Oct 12, 2025
cf5344b
chore(deps): update typescript-eslint monorepo to v8.46.1 (#353)
renovate[bot] Oct 13, 2025
649ad1e
fix file naming for photo upload to include userId
joeboppell Oct 15, 2025
ecaa414
chore(deps): update dependency passkit-generator to v3.5.2 (#356)
renovate[bot] Oct 16, 2025
bf0726d
Merge pull request #351 from Hack-PSU/kyle-edit-inventory
joeboppell Oct 16, 2025
6d73a93
fix inventory item patch route
joeboppell Oct 16, 2025
0c2b93e
chore(deps): update dependency @nestjs/swagger to v11.2.1 (#357)
renovate[bot] Oct 16, 2025
8d98b30
chore(deps): update dependency @types/node to v22.18.11 (#359)
renovate[bot] Oct 17, 2025
f7c7901
chore(deps): update eslint monorepo to v9.38.0 (#360)
renovate[bot] Oct 18, 2025
882ed33
feat(photo): add approval workflow for photos and enhance metadata ha…
kensac Oct 18, 2025
1b661a9
Merge pull request #362 from Hack-PSU/kanishk/photo-approval
kensac Oct 18, 2025
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
13 changes: 0 additions & 13 deletions db/migrations/20250918152536_add_reservations_table.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,6 @@
import type { Knex } from "knex";

export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable("locations", (table) => {
table.index("id");
});

await knex.schema.alterTable("teams", (table) => {
table.index("id");
});

await knex.schema.createTable("reservations", (table) => {
table.increments("id").primary().notNullable();
table.bigInteger("start_time").unsigned().notNullable();
Expand Down Expand Up @@ -46,9 +38,4 @@ export async function up(knex: Knex): Promise<void> {

export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists("reservations");

// Remove the index we added
await knex.schema.alterTable("locations", (table) => {
table.dropIndex("id");
});
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@
"rimraf": "^6.0.1",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "29.4.4",
"ts-jest": "29.4.5",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
Expand Down
18 changes: 0 additions & 18 deletions src/entities/reservation.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,24 +11,6 @@ export enum ReservationType {
@Table({
name: "reservations",
hackathonId: "hackathonId",
relationMappings: {
location: {
relation: Entity.BelongsToOneRelation,
modelClass: "location.entity.js",
join: {
from: "reservations.locationId",
to: "locations.id",
},
},
hackathon: {
relation: Entity.BelongsToOneRelation,
modelClass: "hackathon.entity.js",
join: {
from: "reservations.hackathonId",
to: "hackathons.id",
},
},
},
})
export class Reservation extends Entity {
@ApiProperty()
Expand Down
32 changes: 32 additions & 0 deletions src/modules/inventory/inventory.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
NotFoundException,
Param,
Patch,
Post,
Req,
UsePipes,
Expand Down Expand Up @@ -60,6 +63,12 @@ class CreateItemDto extends IntersectionType(
OptionalItemStatus,
) {}

class UpdateItemDto extends OmitType(BaseCreateItemDto, [
"categoryId",
"holderLocationId",
"holderOrganizerId"
] as const) {}

// Movement
class CreateMovementDto extends OmitType(InventoryMovementEntity, [
"id",
Expand Down Expand Up @@ -164,6 +173,29 @@ export class InventoryController {
return this.itemRepo.createOne(item).exec();
}

@Patch("items/:id")
@Roles(Role.TEAM)
@ApiDoc({
summary: "Update an inventory item's name, asset tag, serial number, or note",
request: { body: { type: UpdateItemDto }, validate: true },
params: [{ name: "id" }],
response: { ok: { type: InventoryItemEntity } },
auth: Role.TEAM
})
async updateItem(@Param("id") id: string, @Body() dto: UpdateItemDto): Promise<InventoryItem> {
// Validate the dto: item should (at least) have one of the following: name, asset tag, serial number
if (!dto.name.trim() && !dto.assetTag.trim() && !dto.serialNumber.trim())
throw new ForbiddenException("Item must have a name, asset tag, or serial number");

// Get the item
const item = await this.itemRepo.findOne(id).exec();
if (!item) throw new NotFoundException("Item not found");

// Update the new values and patch
Object.assign(item, dto);
return this.itemRepo.patchOne(id, item).exec();
}

@Delete("items/:id")
@Roles(Role.TEAM)
@ApiDoc({
Expand Down
2 changes: 1 addition & 1 deletion src/modules/location/location.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export class LocationController {
) {}

@Get("/")
@Roles(Role.TEAM)
@Roles(Role.NONE)
@ApiDoc({
summary: "Find All Locations",
response: {
Expand Down
119 changes: 116 additions & 3 deletions src/modules/photo/photo.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ import {
Get,
InternalServerErrorException,
Post,
Patch,
Param,
UseInterceptors,
Req,
Body,
UnauthorizedException,
} from "@nestjs/common";
import { Request } from "express";
import { ApiTags } from "@nestjs/swagger";
Expand Down Expand Up @@ -51,7 +54,11 @@ export class PhotoController {
throw new BadRequestException("Photo is required");
}

const userId = (req.user as any)?.uid;
if (!req.user || !("sub" in req.user)) {
throw new UnauthorizedException();
}

const userId = String(req.user.sub);
const type = fileType || "default";

try {
Expand All @@ -70,10 +77,10 @@ export class PhotoController {
@Get("/")
@Roles(Role.NONE)
@ApiDoc({
summary: "Get all photos",
summary: "Get all approved photos",
response: {
ok: {
description: "List of all photos",
description: "List of all approved photos",
schema: {
type: "array",
items: {
Expand All @@ -98,4 +105,110 @@ export class PhotoController {
throw new InternalServerErrorException("Failed to fetch photos");
}
}

@Get("/pending")
@Roles(Role.TEAM)
@ApiDoc({
summary: "Get all photos with approval status (admin only)",
response: {
ok: {
description: "List of all photos with approval status",
schema: {
type: "array",
items: {
type: "object",
properties: {
name: { type: "string" },
url: { type: "string" },
createdAt: { type: "string", format: "date-time" },
uploadedBy: { type: "string" },
approvalStatus: { type: "string" },
},
},
},
},
},
})
async getAllPendingPhotos(): Promise<
{
name: string;
url: string;
createdAt: Date;
uploadedBy: string;
approvalStatus: string;
}[]
> {
try {
return await this.photoService.getAllPendingPhotos();
} catch (error) {
console.error("Error fetching pending photos:", error);
throw new InternalServerErrorException("Failed to fetch pending photos");
}
}

@Patch("/:filename/approve")
@Roles(Role.TEAM)
@ApiDoc({
summary: "Approve a photo (admin only)",
response: {
ok: {
description: "Photo approved successfully",
},
},
})
async approvePhoto(
@Param("filename") filename: string,
@Req() req: Request,
): Promise<{ message: string }> {
if (!req.user || !("sub" in req.user)) {
throw new UnauthorizedException();
}

const adminId = String(req.user.sub);

try {
await this.photoService.updatePhotoApprovalStatus(
filename,
"approved",
adminId,
);
return { message: "Photo approved successfully" };
} catch (error) {
console.error("Error approving photo:", error);
throw new InternalServerErrorException("Failed to approve photo");
}
}

@Patch("/:filename/reject")
@Roles(Role.TEAM)
@ApiDoc({
summary: "Reject a photo (admin only)",
response: {
ok: {
description: "Photo rejected successfully",
},
},
})
async rejectPhoto(
@Param("filename") filename: string,
@Req() req: Request,
): Promise<{ message: string }> {
if (!req.user || !("sub" in req.user)) {
throw new UnauthorizedException();
}

const adminId = String(req.user.sub);

try {
await this.photoService.updatePhotoApprovalStatus(
filename,
"rejected",
adminId,
);
return { message: "Photo rejected successfully" };
} catch (error) {
console.error("Error rejecting photo:", error);
throw new InternalServerErrorException("Failed to reject photo");
}
}
}
64 changes: 63 additions & 1 deletion src/modules/photo/photo.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,14 @@ export class PhotoService {
const blob = this.photoBucket.file(filename);

await blob.save(file.buffer, {
metadata: { contentType: file.mimetype },
metadata: {
contentType: file.mimetype,
metadata: {
approvalStatus: "pending",
uploadedBy: userId,
uploadedAt: new Date().toISOString(),
},
},
});

return { photoId, photoUrl: this.getPublicPhotoUrl(filename) };
Expand All @@ -54,12 +61,67 @@ export class PhotoService {
> {
const [files] = await this.photoBucket.getFiles();

// Filter to only show approved photos
const approvedFiles = files.filter((file) => {
const approvalStatus = file.metadata.metadata?.approvalStatus;
// If metadata is missing, treat as pending (backward compatibility)
// Only show if explicitly approved
return approvalStatus === "approved";
});

return approvedFiles.map((file) => ({
name: file.name,
url: this.getPublicPhotoUrl(file.name),
createdAt: file.metadata.timeCreated
? new Date(file.metadata.timeCreated)
: new Date(),
}));
}

async getAllPendingPhotos(): Promise<
{
name: string;
url: string;
createdAt: Date;
uploadedBy: string;
approvalStatus: string;
}[]
> {
const [files] = await this.photoBucket.getFiles();

// Get all photos with their approval status
return files.map((file) => ({
name: file.name,
url: this.getPublicPhotoUrl(file.name),
createdAt: file.metadata.timeCreated
? new Date(file.metadata.timeCreated)
: new Date(),
uploadedBy: String(file.metadata.metadata?.uploadedBy || "unknown"),
// If metadata is missing, treat as pending (backward compatibility)
approvalStatus: String(file.metadata.metadata?.approvalStatus || "pending"),
}));
}

async updatePhotoApprovalStatus(
filename: string,
status: "approved" | "rejected",
adminId: string,
): Promise<void> {
const file = this.photoBucket.file(filename);
const [existingMetadata] = await file.getMetadata();

// Preserve existing metadata or create new structure
// This handles backward compatibility for photos without metadata
await file.setMetadata({
metadata: {
...existingMetadata.metadata,
approvalStatus: status,
reviewedBy: adminId,
reviewedAt: new Date().toISOString(),
// If uploadedBy/uploadedAt are missing, set defaults for backward compatibility
uploadedBy: existingMetadata.metadata?.uploadedBy || "unknown",
uploadedAt: existingMetadata.metadata?.uploadedAt || existingMetadata.timeCreated || new Date().toISOString(),
},
});
}
}
Loading