Skip to content
Open
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
39 changes: 39 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# CI — S7-Lab-Health/peppermint fork
#
# Runs on every PR and push to main.
# Validates that the Fastify API TypeScript compiles cleanly.
# (Client is Next.js; it builds inside Docker at deploy time.)

name: CI

on:
pull_request:
branches: [main]
push:
branches: [main]

permissions:
contents: read

jobs:
build-api:
name: TypeScript build (apps/api)
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/api

steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "20"

# postinstall runs `prisma generate` — generates TS types from schema,
# no real DB connection needed.
- name: Install dependencies
run: npm install --legacy-peer-deps

- name: TypeScript compile
run: npm run build
77 changes: 77 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Deploy — S7-Lab-Health/peppermint fork
#
# Triggered on push to main.
# Builds the Docker image, pushes to ACR with SHA tag, then updates
# the production Container App to the new revision.
#
# Required GitHub configuration (Settings > Environments > "production"):
# Secrets: AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID
# Variables:
# ACR_LOGIN_SERVER — e.g. acraltairprod.azurecr.io
# CONTAINER_APP_NAME — e.g. ca-peppermint-production
# RESOURCE_GROUP — e.g. rg-altair-prod

name: Deploy

on:
push:
branches: [main]
workflow_dispatch:

permissions:
id-token: write
contents: read

env:
IMAGE_NAME: peppermint

jobs:
build:
name: Build & Push Image
runs-on: ubuntu-latest
environment: production
outputs:
image: ${{ steps.push.outputs.image }}

steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

- uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

- name: Log in to ACR
run: |
ACR_NAME="${{ vars.ACR_LOGIN_SERVER }}"
az acr login --name "${ACR_NAME%.azurecr.io}"

- name: Build and push
id: push
run: |
IMAGE="${{ vars.ACR_LOGIN_SERVER }}/${{ env.IMAGE_NAME }}:${{ github.sha }}"
docker build -t "$IMAGE" .
docker push "$IMAGE"
echo "image=$IMAGE" >> "$GITHUB_OUTPUT"

deploy:
name: Deploy to Production ACA
needs: build
runs-on: ubuntu-latest
environment: production

steps:
- uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

- name: Update Container App image
run: |
az containerapp update \
--name "${{ vars.CONTAINER_APP_NAME }}" \
--resource-group "${{ vars.RESOURCE_GROUP }}" \
--image "${{ needs.build.outputs.image }}"
echo "Deployed: ${{ needs.build.outputs.image }}"
59 changes: 40 additions & 19 deletions apps/api/src/controllers/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,41 +2,62 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import multer from "fastify-multer";
import { prisma } from "../prisma";
import fs from "fs";
import path from "path";

const upload = multer({ dest: "uploads/" });

export function objectStoreRoutes(fastify: FastifyInstance) {
//
// Upload a single file to a ticket
fastify.post(
"/api/v1/storage/ticket/:id/upload/single",
{ preHandler: upload.single("file") },

async (request: FastifyRequest, reply: FastifyReply) => {
console.log(request.file);
console.log(request.body);
const { id } = request.params as { id: string };
const file = (request as any).file;

if (!file) {
return reply.status(400).send({ success: false, message: "No file provided" });
}

const userId = (request.body as any)?.user ?? "";

const uploadedFile = await prisma.ticketFile.create({
data: {
ticketId: request.params.id,
filename: request.file.originalname,
path: request.file.path,
mime: request.file.mimetype,
size: request.file.size,
encoding: request.file.encoding,
userId: request.body.user,
ticketId: id,
filename: file.originalname,
path: file.path,
mime: file.mimetype,
size: file.size,
encoding: file.encoding,
userId,
},
});

console.log(uploadedFile);

reply.send({
success: true,
});
reply.send({ success: true, file: uploadedFile });
}
);

// Get all ticket attachments
// Serve uploaded files
fastify.get(
"/api/v1/storage/ticket/:id/file/:fileId",
async (request: FastifyRequest, reply: FastifyReply) => {
const { fileId } = request.params as { id: string; fileId: string };

const file = await prisma.ticketFile.findUnique({
where: { id: fileId },
});

// Delete an attachment
if (!file) {
return reply.status(404).send({ success: false, message: "File not found" });
}

// Download an attachment
if (!fs.existsSync(file.path)) {
return reply.status(404).send({ success: false, message: "File not found on disk" });
}

reply.type(file.mime);
reply.send(fs.createReadStream(file.path));
}
);
}
61 changes: 60 additions & 1 deletion apps/api/src/controllers/ticket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,24 @@ export function ticketRoutes(fastify: FastifyInstance) {

if (status && issue!.status !== status) {
await statusUpdateNotification(issue, user, status);

// Notify Altair BFF so it can email the ticket submitter
const webhookUrl = process.env.ALTAIR_COMMENT_WEBHOOK_URL;
if (webhookUrl) {
const secret = process.env.ALTAIR_WEBHOOK_SECRET ?? "";
const payload = JSON.stringify({
event: "ticket.status_change",
data: { ticketId: id, newStatus: status, actorName: user?.name ?? "Altair Support" },
});
const signature = secret
? require("crypto").createHmac("sha256", secret).update(payload).digest("hex")
: "";
(globalThis as any).fetch(webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json", "x-peppermint-signature": signature },
body: payload,
}).catch((err: any) => console.error("Status webhook failed:", err));
}
}

reply.send({
Expand Down Expand Up @@ -672,13 +690,35 @@ export function ticketRoutes(fastify: FastifyInstance) {
});

//@ts-expect-error
const { email, title } = ticket;
const { email, title, Number: ticketNumber } = ticket;
if (public_comment && email) {
sendComment(text, title, ticket!.id, email!);
}

await commentNotification(ticket, user);

// Notify Altair BFF so it can email the ticket submitter when support replies
const webhookUrl = process.env.ALTAIR_COMMENT_WEBHOOK_URL;
if (webhookUrl && ticket) {
const secret = process.env.ALTAIR_WEBHOOK_SECRET ?? "";
const payload = JSON.stringify({
event: "ticket.comment",
data: { ticketId: ticket.id, commentText: text },
});
const signature = secret
? require("crypto").createHmac("sha256", secret).update(payload).digest("hex")
: "";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).fetch(webhookUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-peppermint-signature": signature,
},
body: payload,
}).catch((err: any) => console.error("Comment webhook failed:", err));
}

const hog = track();

hog.capture({
Expand Down Expand Up @@ -734,6 +774,25 @@ export function ticketRoutes(fastify: FastifyInstance) {

await sendTicketStatus(ticket);

// Notify Altair BFF so it can email the ticket submitter
const webhookUrl = process.env.ALTAIR_COMMENT_WEBHOOK_URL;
if (webhookUrl) {
const secret = process.env.ALTAIR_WEBHOOK_SECRET ?? "";
const newStatus = status ? "done" : "needs_support";
const payload = JSON.stringify({
event: "ticket.status_change",
data: { ticketId: id, newStatus, actorName: user?.name ?? "Altair Support" },
});
const signature = secret
? require("crypto").createHmac("sha256", secret).update(payload).digest("hex")
: "";
(globalThis as any).fetch(webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json", "x-peppermint-signature": signature },
body: payload,
}).catch((err: any) => console.error("Status webhook failed:", err));
}

const webhook = await prisma.webhooks.findMany({
where: {
type: "ticket_status_changed",
Expand Down
2 changes: 1 addition & 1 deletion apps/client/@/shadcn/components/tickets/TicketKanban.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export default function TicketKanban({ columns, uiSettings }: TicketKanbanProps)
draggable({
element,
dragHandle: element,
data: { ticketId: ticket.id } as const,
getInitialData: () => ({ ticketId: ticket.id }),
});
}}
className="bg-white dark:bg-gray-900 rounded-lg shadow-sm border dark:border-gray-700 p-3 cursor-move hover:shadow-md transition-shadow"
Expand Down
2 changes: 2 additions & 0 deletions apps/client/@/shadcn/types/tickets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export type Ticket = {
id: string;
Number: number;
title: string;
detail?: string;
note?: string;
priority: string;
type: string;
status: string;
Expand Down
Loading