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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"@nestjs/passport": "^11.0.0",
"@nestjs/platform-express": "^11.0.0",
"@nestjs/platform-socket.io": "^11.0.0",
"@nestjs/schedule": "^6.0.1",
"@nestjs/swagger": "^11.0.0",
"@nestjs/websockets": "^11.0.0",
"@sendgrid/mail": "^8.1.4",
Expand Down
8 changes: 8 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ import { InventoryModule } from "modules/inventory/inventory.module";
import { TeamModule } from "modules/team/team.module";
import { PhotoModule } from "modules/photo/photo.module";
import { DriveModule } from "modules/drive/drive.module";
import { NotificationSchedulerModule } from "modules/notification-scheduler/notification-scheduler.module";
import { GotifyModule } from "common/gotify/gotify.module";
import gotifyConfig from "common/gotify/gotify.config";

@Module({
imports: [
Expand All @@ -48,6 +51,7 @@ import { DriveModule } from "modules/drive/drive.module";
sendGridConfig,
appleConfig,
bucketConfig,
gotifyConfig,
],
}),

Expand Down Expand Up @@ -112,6 +116,10 @@ import { DriveModule } from "modules/drive/drive.module";

// Mail
MailModule,

// Gotify & Notifications
GotifyModule,
NotificationSchedulerModule,
],
})
export class AppModule {}
7 changes: 7 additions & 0 deletions src/common/gotify/gotify.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { registerAs } from "@nestjs/config";

export default registerAs("gotify", () => ({
url: process.env.GOTIFY_URL,
token: process.env.GOTIFY_TOKEN,
enabled: process.env.RUNTIME_INSTANCE === "production",
}));
11 changes: 11 additions & 0 deletions src/common/gotify/gotify.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { GotifyService } from "./gotify.service";
import gotifyConfig from "./gotify.config";

@Module({
imports: [ConfigModule.forFeature(gotifyConfig)],
providers: [GotifyService],
exports: [GotifyService],
})
export class GotifyModule {}
179 changes: 179 additions & 0 deletions src/common/gotify/gotify.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Hackathon } from "../../entities/hackathon.entity";

export interface GotifyMessage {
title: string;
message: string;
priority?: number; // 0-10, default is 5
extras?: Record<string, any>;
}

@Injectable()
export class GotifyService {
private readonly logger = new Logger(GotifyService.name);
private readonly gotifyUrl: string;
private readonly gotifyToken: string;
private readonly enabled: boolean;

constructor(private configService: ConfigService) {
this.gotifyUrl = this.configService.get<string>("gotify.url");
this.gotifyToken = this.configService.get<string>("gotify.token");
this.enabled = this.configService.get<boolean>("gotify.enabled");

// Check if required configuration is present
if (this.enabled && (!this.gotifyUrl || !this.gotifyToken)) {
this.logger.warn(
"Gotify is enabled (production) but GOTIFY_URL or GOTIFY_TOKEN is missing - notifications will be skipped",
);
// Disable if config is missing to prevent errors
(this.enabled as any) = false;
} else if (this.enabled) {
this.logger.log(
`Gotify notifications enabled for production instance: ${this.gotifyUrl}`,
);
} else {
this.logger.log(
`Gotify notifications disabled (RUNTIME_INSTANCE: ${this.configService.get("RUNTIME_INSTANCE")})`,
);
}
}

async sendNotification(payload: GotifyMessage): Promise<void> {
if (!this.enabled) {
this.logger.debug(
`Skipping Gotify notification (not production): ${payload.title}`,
);
return;
}

if (!this.gotifyUrl || !this.gotifyToken) {
this.logger.error(
"Gotify URL or token not configured in environment variables",
);
return;
}

try {
const url = `${this.gotifyUrl}/message?token=${this.gotifyToken}`;

const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
title: payload.title,
message: payload.message,
priority: payload.priority ?? 5,
extras: payload.extras,
}),
});

if (!response.ok) {
const errorText = await response.text();
throw new Error(
`Gotify API error: ${response.status} ${response.statusText} - ${errorText}`,
);
}

this.logger.log(`Gotify notification sent: ${payload.title}`);
} catch (error) {
this.logger.error(
`Failed to send Gotify notification: ${error.message}`,
error.stack,
);
}
}

async sendRegistrationNotification(
count: number,
userName: string,
userEmail: string,
): Promise<void> {
await this.sendNotification({
title: "New Registration!",
message: `${userName} (${userEmail}) just registered!\n\nRegistration #${count}\nTotal registrations: ${count}`,
priority: 6,
extras: {
type: "registration",
count,
userName,
userEmail,
},
});
}

async sendCheckinNotification(
userName: string,
userEmail: string,
eventName: string,
totalCheckins: number,
): Promise<void> {
await this.sendNotification({
title: "New Check-in!",
message: `${userName} (${userEmail}) checked in to "${eventName}"\n\nEvent: ${eventName}\nTotal check-ins: ${totalCheckins}`,
priority: 6,
extras: {
type: "checkin",
count: totalCheckins,
userName,
userEmail,
eventName,
},
});
}

async sendCountdownNotification(
daysUntil: number,
hackathonName?: string,
): Promise<void> {
// If hackathon name not provided, try to get from active hackathon
if (!hackathonName) {
const activeHackathon = await Hackathon.query()
.where("active", true)
.first();
hackathonName = activeHackathon?.name || "HackPSU";
}

let message: string;
if (daysUntil === 0) {
message = `${hackathonName} is TODAY! Let's go!`;
} else if (daysUntil === 1) {
message = `Only 1 day until ${hackathonName}! Get ready!`;
} else {
message = `${daysUntil} days until ${hackathonName}!`;
}

await this.sendNotification({
title: "Hackathon Countdown",
message,
priority: 7,
extras: {
type: "countdown",
daysUntil,
hackathonName,
},
});
}

async sendBootstrapNotification(): Promise<void> {
const instance =
this.configService.get<string>("RUNTIME_INSTANCE") || "unknown";

await this.sendNotification({
title: "API Started",
message: `HackPSU ${instance} API is awake and ready to serve.`,
priority: 5,
extras: {
type: "bootstrap",
instance,
timestamp: new Date().toISOString(),
},
});
}

isEnabled(): boolean {
return this.enabled;
}
}
20 changes: 13 additions & 7 deletions src/modules/inventory/inventory.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ class CreateItemDto extends IntersectionType(
class UpdateItemDto extends OmitType(BaseCreateItemDto, [
"categoryId",
"holderLocationId",
"holderOrganizerId"
"holderOrganizerId",
] as const) {}

// Movement
Expand Down Expand Up @@ -176,21 +176,27 @@ export class InventoryController {
@Patch("items/:id")
@Roles(Role.TEAM)
@ApiDoc({
summary: "Update an inventory item's name, asset tag, serial number, or note",
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
auth: Role.TEAM,
})
async updateItem(@Param("id") id: string, @Body() dto: UpdateItemDto): Promise<InventoryItem> {
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");
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();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Module } from "@nestjs/common";
import { ScheduleModule } from "@nestjs/schedule";
import { NotificationSchedulerService } from "./notification-scheduler.service";
import { GotifyModule } from "../../common/gotify/gotify.module";

@Module({
imports: [ScheduleModule.forRoot(), GotifyModule],
providers: [NotificationSchedulerService],
exports: [NotificationSchedulerService],
})
export class NotificationSchedulerModule {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
import { Cron } from "@nestjs/schedule";
import { GotifyService } from "../../common/gotify/gotify.service";
import { Hackathon } from "../../entities/hackathon.entity";
import { DateTime } from "luxon";

@Injectable()
export class NotificationSchedulerService implements OnModuleInit {
private readonly logger = new Logger(NotificationSchedulerService.name);

constructor(private gotifyService: GotifyService) {}

async onModuleInit() {
// NotificationScheduler initialized
this.logger.log("NotificationScheduler initialized");
// Bootstrap notification disabled
}
@Cron("0 6 * * *", {
name: "daily-countdown",
timeZone: "America/New_York", // EST/EDT timezone
})
async handleDailyCountdown() {
if (!this.gotifyService.isEnabled()) {
this.logger.debug("Skipping daily countdown (not production)");
return;
}

try {
// Get the active hackathon
const activeHackathon = await Hackathon.query()
.where("active", true)
.first();

if (!activeHackathon) {
this.logger.warn("No active hackathon found, skipping countdown");
return;
}

// Convert Unix timestamp to DateTime
const startDate = DateTime.fromMillis(activeHackathon.startTime, {
zone: "America/New_York",
}).startOf("day");

const today = DateTime.now().setZone("America/New_York").startOf("day");
const daysUntil = Math.ceil(startDate.diff(today, "days").days);

// Only send if the event hasn't passed and is within 30 days
if (daysUntil >= 0 && daysUntil <= 30) {
await this.gotifyService.sendCountdownNotification(daysUntil);
this.logger.log(
`Daily countdown sent: ${daysUntil} days until ${activeHackathon.name}`,
);
} else if (daysUntil < 0) {
this.logger.debug("Event has passed, skipping countdown");
} else {
this.logger.debug(
`Event is too far away (${daysUntil} days), skipping countdown`,
);
}
} catch (error) {
this.logger.error(
`Failed to send daily countdown: ${error.message}`,
error.stack,
);
}
}
}
2 changes: 1 addition & 1 deletion src/modules/photo/photo.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export class PhotoController {
if (!req.user || !("sub" in req.user)) {
throw new UnauthorizedException();
}

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

Expand Down
9 changes: 7 additions & 2 deletions src/modules/photo/photo.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,9 @@ export class PhotoService {
: 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"),
approvalStatus: String(
file.metadata.metadata?.approvalStatus || "pending",
),
}));
}

Expand All @@ -120,7 +122,10 @@ export class PhotoService {
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(),
uploadedAt:
existingMetadata.metadata?.uploadedAt ||
existingMetadata.timeCreated ||
new Date().toISOString(),
},
});
}
Expand Down
Loading