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
13 changes: 13 additions & 0 deletions migrations/004_add_admin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/** @param {import('knex').Knex} knex */
export function up(knex) {
return knex.schema.alterTable('users', (table) => {
table.boolean('admin').defaultTo(false).notNullable();
});
Comment on lines +3 to +5
Copy link

Copilot AI Jan 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The migration adds the 'admin' column but doesn't remove the 'role' column that was defined in the initial migration (001_create_users.js line 17). This leaves both columns in the database, which could lead to confusion and data inconsistencies. Consider either: 1) dropping the 'role' column in this migration, or 2) migrating the data from 'role' to 'admin' (e.g., setting admin=true where role='admin') before dropping the old column.

Suggested change
return knex.schema.alterTable('users', (table) => {
table.boolean('admin').defaultTo(false).notNullable();
});
return knex.schema
.alterTable('users', (table) => {
table.boolean('admin').defaultTo(false).notNullable();
})
.then(() =>
knex('users')
.where('role', 'admin')
.update({ admin: true })
)
.then(() =>
knex.schema.alterTable('users', (table) => {
table.dropColumn('role');
})
);

Copilot uses AI. Check for mistakes.
}

/** @param {import('knex').Knex} knex */
export function down(knex) {
return knex.schema.alterTable('users', (table) => {
table.dropColumn('admin');
});
}
12 changes: 12 additions & 0 deletions src/lib/server/airtable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { AIRTABLE_API_KEY, AIRTABLE_BASE_ID } from '$env/static/private';

export { AIRTABLE_API_KEY, AIRTABLE_BASE_ID };

export function escapeAirtableString(value: string): string {
return value.replace(/'/g, "''");
}

export function buildFilterFormula(field: string, value: string): string {
const safeValue = escapeAirtableString(value);
return encodeURIComponent(`{${field}} = '${safeValue}'`);
}
2 changes: 1 addition & 1 deletion src/lib/server/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ export interface User {
legal_first_name: string | null;
legal_last_name: string | null;
addresses: Address[];
role: 'user' | 'admin';
snowflakes: number;
admin: boolean;
created_at: Date;
updated_at: Date;
}
Expand Down
262 changes: 262 additions & 0 deletions src/routes/admin/+page.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
import { redirect, fail } from '@sveltejs/kit';
import { AIRTABLE_API_KEY, AIRTABLE_BASE_ID } from '$env/static/private';
import type { PageServerLoad, Actions } from './$types';

interface ShopItemRecord {
id: string;
fields: {
'Item Name': string;
};
}

interface ShopOrderRecord {
id: string;
fields: {
Name: string;
Item?: string[];
Email: string;
'Address Line 1': string;
'Address Line 2'?: string;
City: string;
State: string;
Country: string;
'Zip Code': string;
'Phone Number': string;
Notes?: string;
Status?: string;
'Shipping Information'?: string;
};
}

interface SnowflakeRecord {
id: string;
fields: {
Email?: string;
'Snowflake Count'?: number;
};
}

const COMPLETED_STATUSES = ['Rejected With Refund', 'Rejected Without Refund', 'Shipped'];

export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user) {
throw redirect(302, '/landing');
}

if (!locals.user.admin) {
throw redirect(302, '/');
}

const [ordersResponse, itemsResponse] = await Promise.all([
fetch(`https://api.airtable.com/v0/${AIRTABLE_BASE_ID}/Shop%20Orders`, {
headers: {
Authorization: `Bearer ${AIRTABLE_API_KEY}`,
'Content-Type': 'application/json'
}
}),
fetch(`https://api.airtable.com/v0/${AIRTABLE_BASE_ID}/Shop`, {
headers: {
Authorization: `Bearer ${AIRTABLE_API_KEY}`,
'Content-Type': 'application/json'
}
})
]);

if (!ordersResponse.ok) {
return { orders: [], error: 'Failed to fetch orders' };
}

const ordersData = await ordersResponse.json();
const itemsData = itemsResponse.ok ? await itemsResponse.json() : { records: [] };

const itemsMap = new Map<string, string>();
for (const item of itemsData.records as ShopItemRecord[]) {
itemsMap.set(item.id, item.fields['Item Name']);
}

const orders = (ordersData.records as ShopOrderRecord[])
.filter((order) => !COMPLETED_STATUSES.includes(order.fields.Status ?? ''))
.map((order) => ({
id: order.id,
name: order.fields.Name,
email: order.fields.Email,
itemId: order.fields.Item?.[0] ?? null,
itemName: order.fields.Item?.[0] ? (itemsMap.get(order.fields.Item[0]) ?? 'Unknown Item') : 'Unknown Item',
addressLine1: order.fields['Address Line 1'],
addressLine2: order.fields['Address Line 2'] ?? '',
city: order.fields.City,
state: order.fields.State,
country: order.fields.Country,
zipCode: order.fields['Zip Code'],
phoneNumber: order.fields['Phone Number'],
notes: order.fields.Notes ?? '',
status: order.fields.Status ?? 'Pending'
}));

return { orders };
};

export const actions: Actions = {
rejectWithRefund: async ({ request, locals }) => {
if (!locals.user?.admin) {
return fail(403, { error: 'Unauthorized' });
}

const formData = await request.formData();
const orderId = formData.get('orderId')?.toString();
const userEmail = formData.get('userEmail')?.toString();
const itemId = formData.get('itemId')?.toString();

if (!orderId || !userEmail || !itemId) {
return fail(400, { error: 'Missing required fields' });
}

const itemResponse = await fetch(`https://api.airtable.com/v0/${AIRTABLE_BASE_ID}/Shop/${itemId}`, {
headers: {
Authorization: `Bearer ${AIRTABLE_API_KEY}`,
'Content-Type': 'application/json'
}
});

let refundAmount = 0;
if (itemResponse.ok) {
const item = await itemResponse.json();
const costMatch = item.fields?.Cost?.match(/^(\d+)\s+Snowflakes?$/i);
if (costMatch) {
refundAmount = parseInt(costMatch[1], 10);
}
}

if (refundAmount > 0) {
const snowflakeResponse = await fetch(
`https://api.airtable.com/v0/${AIRTABLE_BASE_ID}/Snowflake%20Count`,
{
headers: {
Authorization: `Bearer ${AIRTABLE_API_KEY}`,
'Content-Type': 'application/json'
}
}
);

if (snowflakeResponse.ok) {
const snowflakeData = await snowflakeResponse.json();
const userRecord = snowflakeData.records?.find(
(r: SnowflakeRecord) => r.fields?.Email?.trim() === userEmail
);
Comment on lines +131 to +145
Copy link

Copilot AI Jan 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The refund operation fetches ALL records from the Snowflake Count table and then filters in JavaScript. This is inefficient and will not scale well as the number of users grows. Consider using Airtable's filterByFormula parameter to filter on the server side, similar to how it's done in the profile page (profile/+page.server.ts line 44 uses buildFilterFormula).

Copilot uses AI. Check for mistakes.

if (userRecord) {
const currentBalance = userRecord.fields?.['Snowflake Count'] ?? 0;
await fetch(
`https://api.airtable.com/v0/${AIRTABLE_BASE_ID}/Snowflake%20Count/${userRecord.id}`,
{
method: 'PATCH',
headers: {
Authorization: `Bearer ${AIRTABLE_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
fields: {
'Snowflake Count': currentBalance + refundAmount
}
})
}
);
}
}
}
Comment on lines +149 to +166
Copy link

Copilot AI Jan 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The refund operation does not check if the snowflake update was successful before marking the order as rejected with refund. If the refund fails (e.g., due to network error or API failure), the order status will still be changed to 'Rejected With Refund', but the user won't actually receive their snowflakes back. This creates a financial discrepancy. Consider checking the response status and only updating the order status if the refund succeeds, or implement compensating transactions.

Copilot uses AI. Check for mistakes.

const updateResponse = await fetch(
`https://api.airtable.com/v0/${AIRTABLE_BASE_ID}/Shop%20Orders/${orderId}`,
{
method: 'PATCH',
headers: {
Authorization: `Bearer ${AIRTABLE_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
fields: {
Status: 'Rejected With Refund'
}
})
}
);

if (!updateResponse.ok) {
return fail(500, { error: 'Failed to update order status' });
}

throw redirect(302, '/admin');
},

rejectWithoutRefund: async ({ request, locals }) => {
if (!locals.user?.admin) {
return fail(403, { error: 'Unauthorized' });
}

const formData = await request.formData();
const orderId = formData.get('orderId')?.toString();

if (!orderId) {
return fail(400, { error: 'Missing order ID' });
}

const updateResponse = await fetch(
`https://api.airtable.com/v0/${AIRTABLE_BASE_ID}/Shop%20Orders/${orderId}`,
{
method: 'PATCH',
headers: {
Authorization: `Bearer ${AIRTABLE_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
fields: {
Status: 'Rejected Without Refund'
}
})
}
);

if (!updateResponse.ok) {
return fail(500, { error: 'Failed to update order status' });
}

throw redirect(302, '/admin');
},

markShipped: async ({ request, locals }) => {
if (!locals.user?.admin) {
return fail(403, { error: 'Unauthorized' });
}

const formData = await request.formData();
const orderId = formData.get('orderId')?.toString();
const trackingLabel = formData.get('trackingLabel')?.toString();

if (!orderId || !trackingLabel) {
return fail(400, { error: 'Missing required fields' });
}

const updateResponse = await fetch(
`https://api.airtable.com/v0/${AIRTABLE_BASE_ID}/Shop%20Orders/${orderId}`,
{
method: 'PATCH',
headers: {
Authorization: `Bearer ${AIRTABLE_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
fields: {
Status: 'Shipped',
'Shipping Information': trackingLabel
}
})
}
);

if (!updateResponse.ok) {
return fail(500, { error: 'Failed to update order status' });
}

throw redirect(302, '/admin');
}
};
Loading