typos and stuff

This commit is contained in:
End Nightshade 2026-03-05 10:16:26 -07:00
parent a3feec9263
commit 5d6e7eab52
No known key found for this signature in database
5 changed files with 144 additions and 34 deletions

View file

@ -216,6 +216,7 @@ export async function notifyProjectReview({
projectId,
action,
feedbackForAuthor,
rejectionReason,
reviewerSlackId,
adminSlackIds,
scrapsAwarded,
@ -227,6 +228,7 @@ export async function notifyProjectReview({
projectId: number;
action: "approved" | "denied" | "permanently_rejected";
feedbackForAuthor: string;
rejectionReason?: string;
reviewerSlackId?: string | null;
adminSlackIds: string[];
scrapsAwarded?: number;
@ -301,14 +303,19 @@ export async function notifyProjectReview({
},
];
} else if (action === "permanently_rejected") {
fallbackText = `:scraps: hey <@${userSlackId}>! your scraps project ${projectName} has been unshipped by an admin.`;
const reasonSuffix = rejectionReason ? ` reason: ${rejectionReason}` : "";
const reasonBlock = rejectionReason
? `\n\n*reason:* ${rejectionReason}`
: "";
fallbackText = `:scraps: hey <@${userSlackId}>! your scraps project ${projectName} has been unshipped by an admin.${reasonSuffix}`;
blocks = [
{
type: "section",
text: {
type: "mrkdwn",
text: `:scraps: hey <@${userSlackId}>!\n\nyour scraps project *<${projectUrl}|${projectName}>* has been unshipped by an admin.`,
text: `:scraps: hey <@${userSlackId}>!\n\nyour scraps project *<${projectUrl}|${projectName}>* has been unshipped by an admin.${reasonBlock}`,
},
},
];

View file

@ -45,7 +45,12 @@ const admin = new Elysia({ prefix: "/admin" });
async function requireReviewer(headers: Record<string, string>) {
const user = await getUserFromSession(headers);
if (!user) return null;
if (user.role !== "reviewer" && user.role !== "admin" && user.role !== "creator") return null;
if (
user.role !== "reviewer" &&
user.role !== "admin" &&
user.role !== "creator"
)
return null;
return user;
}
@ -293,8 +298,9 @@ admin.get("/stats", async ({ headers, status }) => {
const fulfilledConsolationDollarCost =
Number(fulfilledConsolationCount[0]?.count || 0) * 2;
const fulfilledUpgradeDollarCost =
Number((fulfilledUpgrades.rows[0] as { total_cost: string })?.total_cost || 0) /
SCRAPS_PER_DOLLAR;
Number(
(fulfilledUpgrades.rows[0] as { total_cost: string })?.total_cost || 0,
) / SCRAPS_PER_DOLLAR;
const totalActualCost =
fulfilledLuckWinDollarCost +
fulfilledConsolationDollarCost +
@ -313,7 +319,9 @@ admin.get("/stats", async ({ headers, status }) => {
{ headers: { Accept: "application/json" } },
);
if (hcbRes.ok) {
const hcbData = await hcbRes.json() as { balances?: { balance_cents?: number } };
const hcbData = (await hcbRes.json()) as {
balances?: { balance_cents?: number };
};
hcbBalanceCents = hcbData.balances?.balance_cents ?? 0;
}
} catch {
@ -439,7 +447,10 @@ admin.get("/users", async ({ headers, query, status }) => {
username: u.username,
avatar: u.avatar,
slackId: u.slackId,
email: (user.role === "admin" || user.role === "creator") ? u.email : undefined,
email:
user.role === "admin" || user.role === "creator"
? u.email
: undefined,
scraps: u.scraps,
role: u.role,
internalNotes: u.internalNotes,
@ -514,7 +525,10 @@ admin.get("/users/:id", async ({ params, headers, status }) => {
let hackatimeBanned = false;
if (targetUser[0].email) {
try {
const htUser = await getHackatimeUser(targetUser[0].email, targetUser[0].slackId);
const htUser = await getHackatimeUser(
targetUser[0].email,
targetUser[0].slackId,
);
if (htUser) {
hackatimeSuspected = htUser.suspected || false;
hackatimeBanned = htUser.banned || false;
@ -530,7 +544,10 @@ admin.get("/users/:id", async ({ params, headers, status }) => {
username: targetUser[0].username,
avatar: targetUser[0].avatar,
slackId: targetUser[0].slackId,
email: (user.role === "admin" || user.role === "creator") ? targetUser[0].email : undefined,
email:
user.role === "admin" || user.role === "creator"
? targetUser[0].email
: undefined,
scraps: scrapsBalance.balance,
role: targetUser[0].role,
internalNotes: targetUser[0].internalNotes,
@ -876,7 +893,10 @@ admin.get("/reviews/:id", async ({ params, headers }) => {
let hackatimeBanned = false;
if (projectUser[0]?.email) {
try {
const htUser = await getHackatimeUser(projectUser[0].email, projectUser[0].slackId);
const htUser = await getHackatimeUser(
projectUser[0].email,
projectUser[0].slackId,
);
if (htUser) {
hackatimeUserId = htUser.user_id;
hackatimeSuspected = htUser.suspected || false;
@ -944,6 +964,7 @@ admin.post("/reviews/:id", async ({ params, body, headers }) => {
hoursOverride,
tierOverride,
userInternalNotes,
rejectionReason,
} = body as {
action: "approved" | "denied" | "permanently_rejected";
feedbackForAuthor: string;
@ -951,6 +972,7 @@ admin.post("/reviews/:id", async ({ params, body, headers }) => {
hoursOverride?: number;
tierOverride?: number;
userInternalNotes?: string;
rejectionReason?: string;
};
if (!["approved", "denied", "permanently_rejected"].includes(action)) {
@ -961,6 +983,12 @@ admin.post("/reviews/:id", async ({ params, body, headers }) => {
return { error: "Feedback for author is required" };
}
if (action === "permanently_rejected" && !rejectionReason?.trim()) {
return {
error: "Reason shown to user is required for permanent rejection",
};
}
const projectId = parseInt(params.id);
// Get project to find user
@ -1133,8 +1161,11 @@ admin.post("/reviews/:id", async ({ params, body, headers }) => {
// Trigger immediate Airtable sync when a project is shipped or updated
if (canShipDirectly && action === "approved") {
syncProjectsToAirtable().catch(err =>
console.error("[ADMIN] Failed to trigger Airtable sync after ship:", err)
syncProjectsToAirtable().catch((err) =>
console.error(
"[ADMIN] Failed to trigger Airtable sync after ship:",
err,
),
);
}
@ -1158,7 +1189,12 @@ admin.post("/reviews/:id", async ({ params, body, headers }) => {
const admins = await db
.select({ slackId: usersTable.slackId })
.from(usersTable)
.where(or(eq(usersTable.role, "admin"), eq(usersTable.role, "creator")));
.where(
or(
eq(usersTable.role, "admin"),
eq(usersTable.role, "creator"),
),
);
adminSlackIds = admins
.map((a) => a.slackId)
@ -1174,6 +1210,7 @@ admin.post("/reviews/:id", async ({ params, body, headers }) => {
projectId,
action,
feedbackForAuthor,
rejectionReason: rejectionReason?.trim() || undefined,
reviewerSlackId,
adminSlackIds,
scrapsAwarded,
@ -1349,7 +1386,10 @@ admin.get("/second-pass/:id", async ({ params, headers }) => {
let hackatimeBanned = false;
if (projectUser[0]?.email) {
try {
const htUser = await getHackatimeUser(projectUser[0].email, projectUser[0].slackId);
const htUser = await getHackatimeUser(
projectUser[0].email,
projectUser[0].slackId,
);
if (htUser) {
hackatimeUserId = htUser.user_id;
hackatimeSuspected = htUser.suspected || false;
@ -1524,8 +1564,11 @@ admin.post("/second-pass/:id", async ({ params, body, headers }) => {
}
// Trigger immediate Airtable sync after shipping
syncProjectsToAirtable().catch(err =>
console.error("[ADMIN] Failed to trigger Airtable sync after second-pass ship:", err)
syncProjectsToAirtable().catch((err) =>
console.error(
"[ADMIN] Failed to trigger Airtable sync after second-pass ship:",
err,
),
);
return { success: true, scrapsAwarded };
@ -2332,7 +2375,9 @@ admin.get("/orders", async ({ headers, query, status }) => {
const rows = await ordersQuery;
// Batch-check Hackatime ban status for unique user emails
const uniqueEmails = [...new Set(rows.map((r) => r.userEmail).filter(Boolean))] as string[];
const uniqueEmails = [
...new Set(rows.map((r) => r.userEmail).filter(Boolean)),
] as string[];
const emailToSlackId = new Map<string, string | null>();
for (const row of rows) {
if (row.userEmail && !emailToSlackId.has(row.userEmail)) {
@ -2343,7 +2388,10 @@ admin.get("/orders", async ({ headers, query, status }) => {
await Promise.all(
uniqueEmails.map(async (email) => {
try {
const htUser = await getHackatimeUser(email, emailToSlackId.get(email));
const htUser = await getHackatimeUser(
email,
emailToSlackId.get(email),
);
banMap.set(email, htUser?.banned ?? false);
} catch {
banMap.set(email, false);
@ -2488,7 +2536,10 @@ admin.patch("/orders/:id", async ({ params, body, headers, status }) => {
const [orderItem] = await db
.select({ name: shopItemsTable.name })
.from(shopItemsTable)
.innerJoin(shopOrdersTable, eq(shopOrdersTable.shopItemId, shopItemsTable.id))
.innerJoin(
shopOrdersTable,
eq(shopOrdersTable.shopItemId, shopItemsTable.id),
)
.where(eq(shopOrdersTable.id, parseInt(params.id)))
.limit(1);
@ -2498,7 +2549,7 @@ admin.patch("/orders/:id", async ({ params, body, headers, status }) => {
itemName: orderItem.name,
trackingNumber: updated[0].trackingNumber,
token: config.slackBotToken,
}).catch((err) => console.error('Failed to send fulfillment DM:', err));
}).catch((err) => console.error("Failed to send fulfillment DM:", err));
}
}
@ -3254,7 +3305,9 @@ admin.post(
async ({ params, headers, body, status }) => {
try {
const admin = await requireAdmin(headers as Record<string, string>);
const creator = !admin ? await requireCreator(headers as Record<string, string>) : null;
const creator = !admin
? await requireCreator(headers as Record<string, string>)
: null;
const user = admin || creator;
if (!user) return status(401, { error: "Unauthorized" });
@ -3642,25 +3695,37 @@ admin.delete("/users/:id", async ({ params, headers, status }) => {
await db.delete(sessionsTable).where(eq(sessionsTable.userId, targetId));
// 2. Delete user activity
await db.delete(userActivityTable).where(eq(userActivityTable.userId, targetId));
await db
.delete(userActivityTable)
.where(eq(userActivityTable.userId, targetId));
// 3. Delete shop hearts
await db.delete(shopHeartsTable).where(eq(shopHeartsTable.userId, targetId));
await db
.delete(shopHeartsTable)
.where(eq(shopHeartsTable.userId, targetId));
// 4. Delete shop penalties
await db.delete(shopPenaltiesTable).where(eq(shopPenaltiesTable.userId, targetId));
await db
.delete(shopPenaltiesTable)
.where(eq(shopPenaltiesTable.userId, targetId));
// 5. Delete shop rolls
await db.delete(shopRollsTable).where(eq(shopRollsTable.userId, targetId));
// 6. Delete refinery spending history
await db.delete(refinerySpendingHistoryTable).where(eq(refinerySpendingHistoryTable.userId, targetId));
await db
.delete(refinerySpendingHistoryTable)
.where(eq(refinerySpendingHistoryTable.userId, targetId));
// 7. Delete refinery orders
await db.delete(refineryOrdersTable).where(eq(refineryOrdersTable.userId, targetId));
await db
.delete(refineryOrdersTable)
.where(eq(refineryOrdersTable.userId, targetId));
// 8. Delete shop orders
await db.delete(shopOrdersTable).where(eq(shopOrdersTable.userId, targetId));
await db
.delete(shopOrdersTable)
.where(eq(shopOrdersTable.userId, targetId));
// 9. Null out givenBy on bonuses given by this user (so other users' bonuses remain)
await db
@ -3669,21 +3734,29 @@ admin.delete("/users/:id", async ({ params, headers, status }) => {
.where(eq(userBonusesTable.givenBy, targetId));
// 10. Delete user's own bonuses
await db.delete(userBonusesTable).where(eq(userBonusesTable.userId, targetId));
await db
.delete(userBonusesTable)
.where(eq(userBonusesTable.userId, targetId));
// 11. Delete reviews done by this user as reviewer
await db.delete(reviewsTable).where(eq(reviewsTable.reviewerId, targetId));
if (projectIds.length > 0) {
// 12. Delete project activity for user's projects (from any user)
await db.delete(projectActivityTable).where(inArray(projectActivityTable.projectId, projectIds));
await db
.delete(projectActivityTable)
.where(inArray(projectActivityTable.projectId, projectIds));
// 13. Delete reviews on user's projects
await db.delete(reviewsTable).where(inArray(reviewsTable.projectId, projectIds));
await db
.delete(reviewsTable)
.where(inArray(reviewsTable.projectId, projectIds));
}
// 14. Delete project activity by this user (on any project)
await db.delete(projectActivityTable).where(eq(projectActivityTable.userId, targetId));
await db
.delete(projectActivityTable)
.where(eq(projectActivityTable.userId, targetId));
// 15. Delete user's projects
await db.delete(projectsTable).where(eq(projectsTable.userId, targetId));

View file

@ -455,6 +455,7 @@ export default {
noRefinements: 'no refinements to show',
roles: {
admin: 'admin',
creator: 'creator',
reviewer: 'reviewer',
member: 'member',
banned: 'banned'

View file

@ -457,6 +457,7 @@ export default {
noRefinements: 'no hay refinamientos para mostrar',
roles: {
admin: 'admin',
creator: 'creador',
reviewer: 'revisor',
member: 'miembro',
banned: 'baneado'

View file

@ -134,6 +134,7 @@
let confirmAction = $state<'approved' | 'denied' | 'permanently_rejected' | null>(null);
let errorModal = $state<string | null>(null);
let rejectionReason = $state('');
let projectId = $derived(page.params.id);
@ -227,6 +228,10 @@
error = 'Internal justification is required';
return;
}
if (action === 'permanently_rejected' && !rejectionReason.trim()) {
error = 'Reason shown to user is required for permanent rejection';
return;
}
confirmAction = action;
}
@ -247,6 +252,10 @@
error = 'Internal justification is required';
return;
}
if (confirmAction === 'permanently_rejected' && !rejectionReason.trim()) {
error = 'Reason shown to user is required for permanent rejection';
return;
}
submitting = true;
error = null;
@ -262,7 +271,11 @@
internalJustification: internalJustification || undefined,
hoursOverride: hoursOverride !== undefined ? hoursOverride : undefined,
tierOverride: tierOverride !== undefined ? tierOverride : undefined,
userInternalNotes: userInternalNotes || undefined
userInternalNotes: userInternalNotes || undefined,
rejectionReason:
confirmAction === 'permanently_rejected' && rejectionReason.trim()
? rejectionReason.trim()
: undefined
})
});
@ -931,8 +944,23 @@
class="w-full resize-none rounded-lg border-2 border-black px-4 py-2 focus:border-dashed focus:outline-none"
></textarea>
<p class="mt-1 text-xs text-gray-500">
⚠️ for permanent rejection: this is kept internal — the user will only be told their
project was unshipped by an admin
⚠️ for permanent rejection: the above field is kept internal only
</p>
</div>
<div>
<label class="mb-1 block text-sm font-bold">
reason shown to user <span class="text-red-500">*</span>
<span class="text-gray-400">(for permanent rejection)</span>
</label>
<textarea
bind:value={rejectionReason}
rows="3"
placeholder="This reason will be included in the Slack DM to the user when permanently rejecting."
class="w-full resize-none rounded-lg border-2 border-black px-4 py-2 focus:border-dashed focus:outline-none"
></textarea>
<p class="mt-1 text-xs text-gray-500">
required for permanent rejection — sent to the user via Slack DM
</p>
</div>