build: migrate from TypeScript to plain JavaScript with JSDoc

Rename all .ts sources to .js (type-only modules to .d.ts), strip
Svelte lang="ts" script blocks, and replace TS syntax with JSDoc
annotations. tsconfig.json becomes jsconfig.json with checkJs enabled
so svelte-check keeps strict type coverage without the TypeScript
compiler in the source files. No behavior changes; check/test/build
gates stay green.
This commit is contained in:
2026-08-18 09:31:03 +07:00
parent db63d30625
commit 2f2c8d53f5
39 changed files with 550 additions and 325 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": false,
"checkJs": false,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
+1 -1
View File
@@ -7,7 +7,7 @@
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
"test": "vitest run",
"test:e2e": "playwright test"
},
+2 -2
View File
@@ -1,7 +1,7 @@
import type { Handle } from '@sveltejs/kit';
import { getSessionUser } from '$lib/server/auth/session';
export const handle: Handle = async ({ event, resolve }) => {
/** @type {import('@sveltejs/kit').Handle} */
export const handle = async ({ event, resolve }) => {
event.locals.user = await getSessionUser(event);
return resolve(event);
};
@@ -1,8 +1,9 @@
import { error, type RequestEvent } from '@sveltejs/kit';
import { error } from '@sveltejs/kit';
import { env as privateEnv } from '$env/dynamic/private';
import type { AdminScope } from '$lib/shared/types/domain';
import { requireVerifiedUser } from './session';
/** @typedef {import('$lib/shared/types/domain').AdminScope} AdminScope */
const defaultAdminEmails = ['minhtienit99@gmail.com', 'minhnguyetawf@gmail.com'];
export function configuredAdminEmails() {
@@ -10,18 +11,30 @@ export function configuredAdminEmails() {
return fromEnv?.length ? fromEnv : defaultAdminEmails;
}
export function scopesForEmail(email: string): AdminScope[] {
/**
* @param {string} email
* @returns {AdminScope[]}
*/
export function scopesForEmail(email) {
if (!configuredAdminEmails().includes(email.toLowerCase())) return [];
return ['global', 'vutrudodac', 'phienchotrenmay'];
}
export function hasAdminScope(user: App.ArtemisUser | null, scope: AdminScope) {
/**
* @param {App.ArtemisUser | null} user
* @param {AdminScope} scope
*/
export function hasAdminScope(user, scope) {
if (!user) return false;
const scopes = scopesForEmail(user.email);
return scopes.includes('global') || scopes.includes(scope);
}
export async function requireAdmin(event: RequestEvent, scope: AdminScope) {
/**
* @param {import('@sveltejs/kit').RequestEvent} event
* @param {AdminScope} scope
*/
export async function requireAdmin(event, scope) {
const user = await requireVerifiedUser(event);
if (!hasAdminScope(user, scope)) {
throw error(403, 'Bạn không có quyền admin cho khu vực này.');
@@ -1,14 +1,20 @@
import { fail, redirect, type RequestEvent } from '@sveltejs/kit';
import { fail, redirect } from '@sveltejs/kit';
import { env as privateEnv } from '$env/dynamic/private';
import { createSupabaseServerClient, hasSupabaseConfig } from '$lib/server/supabase/client';
const defaultDevEmail = 'minhtienit99@gmail.com';
function domainFromEmail(email: string) {
/** @param {string} email */
function domainFromEmail(email) {
return email.split('@')[0]?.toLowerCase().replace(/[^a-z0-9._-]/g, '') || 'starter';
}
function makeUser(email: string, id = `dev-${domainFromEmail(email)}`): App.ArtemisUser {
/**
* @param {string} email
* @param {string} [id]
* @returns {App.ArtemisUser}
*/
function makeUser(email, id = `dev-${domainFromEmail(email)}`) {
const domain = domainFromEmail(email);
return {
id,
@@ -20,12 +26,17 @@ function makeUser(email: string, id = `dev-${domainFromEmail(email)}`): App.Arte
};
}
export function getFallbackDevUser(): App.ArtemisUser {
/** @returns {App.ArtemisUser} */
export function getFallbackDevUser() {
const configured = privateEnv.ARTEMIS_DEV_USER_EMAIL || privateEnv.ARTEMIS_ADMIN_EMAILS?.split(',')[0];
return makeUser((configured || defaultDevEmail).trim().toLowerCase());
}
export async function getSessionUser(event: RequestEvent): Promise<App.ArtemisUser | null> {
/**
* @param {import('@sveltejs/kit').RequestEvent} event
* @returns {Promise<App.ArtemisUser | null>}
*/
export async function getSessionUser(event) {
if (!hasSupabaseConfig()) return getFallbackDevUser();
const supabase = createSupabaseServerClient(event);
@@ -58,14 +69,16 @@ export async function getSessionUser(event: RequestEvent): Promise<App.ArtemisUs
};
}
export async function requireVerifiedUser(event: RequestEvent) {
/** @param {import('@sveltejs/kit').RequestEvent} event */
export async function requireVerifiedUser(event) {
const user = event.locals.user ?? (await getSessionUser(event));
if (!user) throw redirect(303, '/account?reason=signin');
if (!user.verifiedEmail) throw redirect(303, '/account?reason=verified-email');
return user;
}
export function requireVerifiedUserForAction(user: App.ArtemisUser | null) {
/** @param {App.ArtemisUser | null} user */
export function requireVerifiedUserForAction(user) {
if (!user) return fail(401, { message: 'Bạn cần đăng nhập Google để gửi tín hiệu.' });
if (!user.verifiedEmail) return fail(403, { message: 'Email Google cần được xác minh trước khi dùng Artemis.' });
return null;
@@ -1,18 +1,23 @@
import type { FoundItem, LostItem, MatchCandidate, MatchLevel } from '$lib/shared/types/domain';
/** @typedef {import('$lib/shared/types/domain').FoundItem} FoundItem */
/** @typedef {import('$lib/shared/types/domain').LostItem} LostItem */
/** @typedef {import('$lib/shared/types/domain').MatchCandidate} MatchCandidate */
/** @typedef {import('$lib/shared/types/domain').MatchLevel} MatchLevel */
export interface MatchableReport {
id: string;
description: string;
occurredAtText: string;
location?: string;
createdAt?: string;
}
/**
* @typedef {object} MatchableReport
* @property {string} id
* @property {string} description
* @property {string} occurredAtText
* @property {string} [location]
* @property {string} [createdAt]
*/
export interface MatchScore {
score: number;
level: MatchLevel;
reasons: string[];
}
/**
* @typedef {object} MatchScore
* @property {number} score
* @property {MatchLevel} level
* @property {string[]} reasons
*/
const stopWords = new Set([
'a',
@@ -43,7 +48,8 @@ const stopWords = new Set([
'va'
]);
export function normalizeSearchText(value: string) {
/** @param {string} value */
export function normalizeSearchText(value) {
return value
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
@@ -53,26 +59,40 @@ export function normalizeSearchText(value: string) {
.trim();
}
export function tokenizeReport(value: string) {
/** @param {string} value */
export function tokenizeReport(value) {
return normalizeSearchText(value)
.split(' ')
.filter((token) => token.length > 1 && !stopWords.has(token));
}
function overlapScore(left: string[], right: string[]) {
/**
* @param {string[]} left
* @param {string[]} right
*/
function overlapScore(left, right) {
if (!left.length || !right.length) return 0;
const rightSet = new Set(right);
const shared = left.filter((token) => rightSet.has(token));
return shared.length / Math.max(left.length, right.length);
}
function timeHintScore(left: string, right: string) {
/**
* @param {string} left
* @param {string} right
*/
function timeHintScore(left, right) {
const leftTokens = tokenizeReport(left);
const rightTokens = tokenizeReport(right);
return overlapScore(leftTokens, rightTokens);
}
export function scoreLostFoundMatch(lost: MatchableReport, found: MatchableReport): MatchScore {
/**
* @param {MatchableReport} lost
* @param {MatchableReport} found
* @returns {MatchScore}
*/
export function scoreLostFoundMatch(lost, found) {
const lostDescription = tokenizeReport(lost.description);
const foundDescription = tokenizeReport(found.description);
const description = overlapScore(lostDescription, foundDescription);
@@ -80,7 +100,8 @@ export function scoreLostFoundMatch(lost: MatchableReport, found: MatchableRepor
const location = found.location ? overlapScore(tokenizeReport(lost.description), tokenizeReport(found.location)) : 0;
const score = Math.round(Math.min(1, description * 0.72 + time * 0.18 + location * 0.1) * 100);
const level: MatchLevel = score >= 62 ? 'strong' : score >= 34 ? 'near' : 'none';
/** @type {MatchLevel} */
const level = score >= 62 ? 'strong' : score >= 34 ? 'near' : 'none';
const reasons = [
description > 0 ? 'mô tả có tín hiệu trùng' : '',
time > 0 ? 'thời gian gần nhau' : '',
@@ -90,7 +111,12 @@ export function scoreLostFoundMatch(lost: MatchableReport, found: MatchableRepor
return { score, level, reasons };
}
export function buildMatchCandidate(lost: LostItem, found: FoundItem): MatchCandidate | null {
/**
* @param {LostItem} lost
* @param {FoundItem} found
* @returns {MatchCandidate | null}
*/
export function buildMatchCandidate(lost, found) {
const result = scoreLostFoundMatch(
{
id: lost.id,
@@ -118,8 +144,13 @@ export function buildMatchCandidate(lost: LostItem, found: FoundItem): MatchCand
};
}
export function findCandidateMatches(lostItems: LostItem[], foundItems: FoundItem[]) {
const candidates: MatchCandidate[] = [];
/**
* @param {LostItem[]} lostItems
* @param {FoundItem[]} foundItems
*/
export function findCandidateMatches(lostItems, foundItems) {
/** @type {MatchCandidate[]} */
const candidates = [];
for (const lost of lostItems) {
if (!['open', 'matched'].includes(lost.status)) continue;
@@ -1,11 +1,16 @@
import type { MarketplaceListing } from '$lib/shared/types/domain';
import { normalizeSearchText, tokenizeReport } from '$lib/server/domain/lost-found/matching';
export interface RankedListing extends MarketplaceListing {
rankScore: number;
}
/** @typedef {import('$lib/shared/types/domain').MarketplaceListing} MarketplaceListing */
export function marketplaceSearchScore(listing: MarketplaceListing, query: string) {
/**
* @typedef {MarketplaceListing & { rankScore: number }} RankedListing
*/
/**
* @param {MarketplaceListing} listing
* @param {string} query
*/
export function marketplaceSearchScore(listing, query) {
const normalizedQuery = normalizeSearchText(query);
if (!normalizedQuery) return listing.careCount;
@@ -18,7 +23,12 @@ export function marketplaceSearchScore(listing: MarketplaceListing, query: strin
return exactNameBoost + tokenScore + Math.min(20, listing.careCount * 2);
}
export function rankMarketplaceListings(listings: MarketplaceListing[], query = ''): RankedListing[] {
/**
* @param {MarketplaceListing[]} listings
* @param {string} [query]
* @returns {RankedListing[]}
*/
export function rankMarketplaceListings(listings, query = '') {
return listings
.filter((listing) => listing.status === 'approved')
.map((listing) => ({ ...listing, rankScore: marketplaceSearchScore(listing, query) }))
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import { rankMarketplaceListings } from './ranking';
import type { MarketplaceListing } from '$lib/shared/types/domain';
/** @typedef {import('$lib/shared/types/domain').MarketplaceListing} MarketplaceListing */
const owner = {
id: 'starter-1',
@@ -10,7 +11,11 @@ const owner = {
verifiedEmail: true
};
function listing(overrides: Partial<MarketplaceListing>): MarketplaceListing {
/**
* @param {Partial<MarketplaceListing>} overrides
* @returns {MarketplaceListing}
*/
function listing(overrides) {
return {
id: 'listing-1',
owner,
@@ -1,43 +1,45 @@
import type {
AdminAction,
AdminScope,
FoundItem,
ImageMetadata,
ListingStatus,
LostItem,
MarketplaceListing,
MatchCandidate,
Notification,
NotificationType,
ProfileSummary,
ReportStatus
} from '$lib/shared/types/domain';
import { findCandidateMatches } from '$lib/server/domain/lost-found/matching';
/** @typedef {import('$lib/shared/types/domain').AdminAction} AdminAction */
/** @typedef {import('$lib/shared/types/domain').AdminScope} AdminScope */
/** @typedef {import('$lib/shared/types/domain').FoundItem} FoundItem */
/** @typedef {import('$lib/shared/types/domain').ImageMetadata} ImageMetadata */
/** @typedef {import('$lib/shared/types/domain').ListingStatus} ListingStatus */
/** @typedef {import('$lib/shared/types/domain').LostItem} LostItem */
/** @typedef {import('$lib/shared/types/domain').MarketplaceListing} MarketplaceListing */
/** @typedef {import('$lib/shared/types/domain').MatchCandidate} MatchCandidate */
/** @typedef {import('$lib/shared/types/domain').Notification} Notification */
/** @typedef {import('$lib/shared/types/domain').NotificationType} NotificationType */
/** @typedef {import('$lib/shared/types/domain').ProfileSummary} ProfileSummary */
/** @typedef {import('$lib/shared/types/domain').ReportStatus} ReportStatus */
function randomId() {
return globalThis.crypto.randomUUID();
}
interface MarketplaceInterest {
listingId: string;
profileId: string;
createdAt: string;
}
/**
* @typedef {object} MarketplaceInterest
* @property {string} listingId
* @property {string} profileId
* @property {string} createdAt
*/
export interface ArtemisMemoryState {
profiles: ProfileSummary[];
lostItems: LostItem[];
foundItems: FoundItem[];
matchCandidates: MatchCandidate[];
listings: MarketplaceListing[];
interests: MarketplaceInterest[];
notifications: Notification[];
adminActions: AdminAction[];
}
/**
* @typedef {object} ArtemisMemoryState
* @property {ProfileSummary[]} profiles
* @property {LostItem[]} lostItems
* @property {FoundItem[]} foundItems
* @property {MatchCandidate[]} matchCandidates
* @property {MarketplaceListing[]} listings
* @property {MarketplaceInterest[]} interests
* @property {Notification[]} notifications
* @property {AdminAction[]} adminActions
*/
const now = () => new Date().toISOString();
const starterProfile: ProfileSummary = {
/** @type {ProfileSummary} */
const starterProfile = {
id: 'seed-starter',
email: 'starter@example.com',
displayName: 'starter',
@@ -45,7 +47,8 @@ const starterProfile: ProfileSummary = {
verifiedEmail: true
};
function emptyState(): ArtemisMemoryState {
/** @returns {ArtemisMemoryState} */
function emptyState() {
return {
profiles: [starterProfile],
lostItems: [
@@ -100,7 +103,11 @@ function emptyState(): ArtemisMemoryState {
const memoryState = emptyState();
memoryState.matchCandidates = findCandidateMatches(memoryState.lostItems, memoryState.foundItems);
function toProfile(user: App.ArtemisUser): ProfileSummary {
/**
* @param {App.ArtemisUser} user
* @returns {ProfileSummary}
*/
function toProfile(user) {
return {
id: user.id,
email: user.email,
@@ -110,7 +117,8 @@ function toProfile(user: App.ArtemisUser): ProfileSummary {
};
}
function ensureProfile(user: App.ArtemisUser) {
/** @param {App.ArtemisUser} user */
function ensureProfile(user) {
const existing = memoryState.profiles.find((profile) => profile.id === user.id);
const next = toProfile(user);
@@ -123,7 +131,8 @@ function ensureProfile(user: App.ArtemisUser) {
return next;
}
function upsertMatchCandidates(candidates: MatchCandidate[]) {
/** @param {MatchCandidate[]} candidates */
function upsertMatchCandidates(candidates) {
for (const candidate of candidates) {
const existing = memoryState.matchCandidates.find(
(match) => match.lostItemId === candidate.lostItemId && match.foundItemId === candidate.foundItemId
@@ -153,15 +162,15 @@ export function resetMemoryStateForTests() {
memoryState.adminActions = next.adminActions;
}
export function createMemoryLostItem(user: App.ArtemisUser, input: {
id?: string;
description: string;
lostAtText: string;
image?: ImageMetadata;
}) {
/**
* @param {App.ArtemisUser} user
* @param {{ id?: string, description: string, lostAtText: string, image?: ImageMetadata }} input
*/
export function createMemoryLostItem(user, input) {
const profile = ensureProfile(user);
const timestamp = now();
const item: LostItem = {
/** @type {LostItem} */
const item = {
id: input.id ?? randomId(),
owner: profile,
description: input.description,
@@ -187,16 +196,15 @@ export function createMemoryLostItem(user: App.ArtemisUser, input: {
return { item, candidates };
}
export function createMemoryFoundItem(user: App.ArtemisUser, input: {
id?: string;
description: string;
foundAtText: string;
location: string;
image?: ImageMetadata;
}) {
/**
* @param {App.ArtemisUser} user
* @param {{ id?: string, description: string, foundAtText: string, location: string, image?: ImageMetadata }} input
*/
export function createMemoryFoundItem(user, input) {
const profile = ensureProfile(user);
const timestamp = now();
const item: FoundItem = {
/** @type {FoundItem} */
const item = {
id: input.id ?? randomId(),
finder: profile,
description: input.description,
@@ -225,7 +233,8 @@ export function createMemoryFoundItem(user: App.ArtemisUser, input: {
return { item, candidates };
}
export function listMemoryLostFound(user?: App.ArtemisUser | null) {
/** @param {App.ArtemisUser | null} [user] */
export function listMemoryLostFound(user) {
const profileId = user?.id;
return {
lostItems: memoryState.lostItems.filter((item) => item.status !== 'hidden').slice(0, 20),
@@ -237,12 +246,13 @@ export function listMemoryLostFound(user?: App.ArtemisUser | null) {
};
}
export function updateMemoryReportStatus(
kind: 'lost' | 'found',
id: string,
status: ReportStatus,
actor: App.ArtemisUser
) {
/**
* @param {'lost' | 'found'} kind
* @param {string} id
* @param {ReportStatus} status
* @param {App.ArtemisUser} actor
*/
export function updateMemoryReportStatus(kind, id, status, actor) {
const collection = kind === 'lost' ? memoryState.lostItems : memoryState.foundItems;
const item = collection.find((entry) => entry.id === id);
if (!item) return null;
@@ -252,18 +262,15 @@ export function updateMemoryReportStatus(
return item;
}
export function createMemoryListing(user: App.ArtemisUser, input: {
id?: string;
name: string;
quantity: number;
description: string;
priceText: string;
contact: string;
image?: ImageMetadata;
}) {
/**
* @param {App.ArtemisUser} user
* @param {{ id?: string, name: string, quantity: number, description: string, priceText: string, contact: string, image?: ImageMetadata }} input
*/
export function createMemoryListing(user, input) {
const profile = ensureProfile(user);
const timestamp = now();
const listing: MarketplaceListing = {
/** @type {MarketplaceListing} */
const listing = {
id: input.id ?? randomId(),
owner: profile,
name: input.name,
@@ -286,7 +293,8 @@ export function createMemoryListing(user: App.ArtemisUser, input: {
return listing;
}
export function listMemoryMarketplace(user?: App.ArtemisUser | null) {
/** @param {App.ArtemisUser | null} [user] */
export function listMemoryMarketplace(user) {
const profileId = user?.id;
return memoryState.listings.map((listing) => ({
...listing,
@@ -300,7 +308,11 @@ export function listMemoryMarketplace(user?: App.ArtemisUser | null) {
}));
}
export function toggleMemoryMarketplaceInterest(user: App.ArtemisUser, listingId: string) {
/**
* @param {App.ArtemisUser} user
* @param {string} listingId
*/
export function toggleMemoryMarketplaceInterest(user, listingId) {
ensureProfile(user);
const listing = memoryState.listings.find((entry) => entry.id === listingId);
if (!listing) return null;
@@ -326,7 +338,12 @@ export function toggleMemoryMarketplaceInterest(user: App.ArtemisUser, listingId
};
}
export function updateMemoryListingStatus(id: string, status: ListingStatus, actor: App.ArtemisUser) {
/**
* @param {string} id
* @param {ListingStatus} status
* @param {App.ArtemisUser} actor
*/
export function updateMemoryListingStatus(id, status, actor) {
const listing = memoryState.listings.find((entry) => entry.id === id);
if (!listing) return null;
listing.status = status;
@@ -339,17 +356,19 @@ export function updateMemoryListingStatus(id: string, status: ListingStatus, act
return listing;
}
export function createMemoryNotification(
recipientId: string,
type: NotificationType,
message: string,
payload: Record<string, unknown>
) {
/**
* @param {string} recipientId
* @param {NotificationType} type
* @param {string} message
* @param {Record<string, unknown>} payload
*/
export function createMemoryNotification(recipientId, type, message, payload) {
const deliveryKey = `${type}:${recipientId}:${JSON.stringify(payload)}`;
const existing = memoryState.notifications.find((notification) => notification.deliveryKey === deliveryKey);
if (existing) return existing;
const notification: Notification = {
/** @type {Notification} */
const notification = {
id: randomId(),
recipientId,
type,
@@ -363,7 +382,11 @@ export function createMemoryNotification(
return notification;
}
export function markMemoryNotificationRead(recipientId: string, notificationId: string) {
/**
* @param {string} recipientId
* @param {string} notificationId
*/
export function markMemoryNotificationRead(recipientId, notificationId) {
const notification = memoryState.notifications.find(
(entry) => entry.id === notificationId && entry.recipientId === recipientId
);
@@ -372,15 +395,17 @@ export function markMemoryNotificationRead(recipientId: string, notificationId:
return notification;
}
export function createMemoryAdminAction(
actor: App.ArtemisUser,
scope: AdminScope,
action: string,
targetType: string,
targetId: string,
payload: Record<string, unknown>
) {
const adminAction: AdminAction = {
/**
* @param {App.ArtemisUser} actor
* @param {AdminScope} scope
* @param {string} action
* @param {string} targetType
* @param {string} targetId
* @param {Record<string, unknown>} payload
*/
export function createMemoryAdminAction(actor, scope, action, targetType, targetId, payload) {
/** @type {AdminAction} */
const adminAction = {
id: randomId(),
actor: ensureProfile(actor),
scope,
@@ -6,13 +6,14 @@ import {
updateMemoryListingStatus
} from './memory-store';
/** @type {App.ArtemisUser} */
const user = {
id: 'dev-user',
email: 'dev@example.com',
displayName: 'dev',
domain: 'dev',
verifiedEmail: true,
authProvider: 'google' as const
authProvider: 'google'
};
describe('memory store idempotency helpers', () => {
@@ -1,4 +1,4 @@
import { error, type RequestEvent } from '@sveltejs/kit';
import { error } from '@sveltejs/kit';
import {
createSupabaseServerClient,
createSupabaseServiceClient,
@@ -12,12 +12,21 @@ import {
updateMemoryReportStatus
} from '$lib/server/persistence/memory-store';
import { findCandidateMatches } from '$lib/server/domain/lost-found/matching';
import type { FoundItem, ImageMetadata, LostItem, ProfileSummary, ReportStatus } from '$lib/shared/types/domain';
type ReportKind = 'lost' | 'found';
type Row = Record<string, unknown>;
/** @typedef {import('$lib/shared/types/domain').FoundItem} FoundItem */
/** @typedef {import('$lib/shared/types/domain').ImageMetadata} ImageMetadata */
/** @typedef {import('$lib/shared/types/domain').LostItem} LostItem */
/** @typedef {import('$lib/shared/types/domain').ProfileSummary} ProfileSummary */
/** @typedef {import('$lib/shared/types/domain').ReportStatus} ReportStatus */
function profileFromUser(user: App.ArtemisUser): ProfileSummary {
/** @typedef {'lost' | 'found'} ReportKind */
/** @typedef {Record<string, unknown>} Row */
/**
* @param {App.ArtemisUser} user
* @returns {ProfileSummary}
*/
function profileFromUser(user) {
return {
id: user.id,
email: user.email,
@@ -27,8 +36,13 @@ function profileFromUser(user: App.ArtemisUser): ProfileSummary {
};
}
function profileFromRow(row: Row, fallback?: ProfileSummary): ProfileSummary {
const profile = (row.profiles ?? row.profile) as Row | undefined;
/**
* @param {Row} row
* @param {ProfileSummary} [fallback]
* @returns {ProfileSummary}
*/
function profileFromRow(row, fallback) {
const profile = /** @type {Row | undefined} */ (row.profiles ?? row.profile);
return {
id: String(row.owner_profile_id ?? row.finder_profile_id ?? profile?.id ?? fallback?.id ?? 'unknown'),
email: String(profile?.email ?? fallback?.email ?? 'unknown@example.com'),
@@ -38,42 +52,60 @@ function profileFromRow(row: Row, fallback?: ProfileSummary): ProfileSummary {
};
}
function imageFromRow(row: Row): ImageMetadata | undefined {
/**
* @param {Row} row
* @returns {ImageMetadata | undefined}
*/
function imageFromRow(row) {
const metadata = row.image_metadata;
if (!metadata || typeof metadata !== 'object') return undefined;
return metadata as ImageMetadata;
return /** @type {ImageMetadata} */ (metadata);
}
function lostFromRow(row: Row, fallback?: ProfileSummary): LostItem {
/**
* @param {Row} row
* @param {ProfileSummary} [fallback]
* @returns {LostItem}
*/
function lostFromRow(row, fallback) {
return {
id: String(row.id),
owner: profileFromRow(row, fallback),
description: String(row.description ?? ''),
lostAtText: String(row.occurred_at_text ?? ''),
status: String(row.status ?? 'open') as ReportStatus,
status: /** @type {ReportStatus} */ (String(row.status ?? 'open')),
image: imageFromRow(row),
payload: (row.payload as Record<string, unknown>) ?? {},
payload: /** @type {Record<string, unknown>} */ (row.payload) ?? {},
createdAt: String(row.created_at ?? new Date().toISOString()),
updatedAt: String(row.updated_at ?? row.created_at ?? new Date().toISOString())
};
}
function foundFromRow(row: Row, fallback?: ProfileSummary): FoundItem {
/**
* @param {Row} row
* @param {ProfileSummary} [fallback]
* @returns {FoundItem}
*/
function foundFromRow(row, fallback) {
return {
id: String(row.id),
finder: profileFromRow(row, fallback),
description: String(row.description ?? ''),
foundAtText: String(row.occurred_at_text ?? ''),
location: String(row.location ?? ''),
status: String(row.status ?? 'open') as ReportStatus,
status: /** @type {ReportStatus} */ (String(row.status ?? 'open')),
image: imageFromRow(row),
payload: (row.payload as Record<string, unknown>) ?? {},
payload: /** @type {Record<string, unknown>} */ (row.payload) ?? {},
createdAt: String(row.created_at ?? new Date().toISOString()),
updatedAt: String(row.updated_at ?? row.created_at ?? new Date().toISOString())
};
}
async function ensureSupabaseProfile(event: RequestEvent, user: App.ArtemisUser) {
/**
* @param {import('@sveltejs/kit').RequestEvent} event
* @param {App.ArtemisUser} user
*/
async function ensureSupabaseProfile(event, user) {
const supabase = createSupabaseServerClient(event);
const { error: profileError } = await supabase.from('profiles').upsert(
{
@@ -90,7 +122,11 @@ async function ensureSupabaseProfile(event: RequestEvent, user: App.ArtemisUser)
if (profileError) throw error(500, `Không thể đồng bộ profile Artemis: ${profileError.message}`);
}
async function listSupabaseReports(event: RequestEvent, user?: App.ArtemisUser | null) {
/**
* @param {import('@sveltejs/kit').RequestEvent} event
* @param {App.ArtemisUser | null} [user]
*/
async function listSupabaseReports(event, user) {
const supabase = createSupabaseServerClient(event);
const [lostResponse, foundResponse, notificationResponse] = await Promise.all([
supabase
@@ -119,9 +155,9 @@ async function listSupabaseReports(event: RequestEvent, user?: App.ArtemisUser |
return { ...listMemoryLostFound(user), warning: 'Supabase chưa sẵn sàng, Artemis đang dùng dữ liệu local.' };
}
const lostItems = (lostResponse.data ?? []).map((row) => lostFromRow(row as Row, user ? profileFromUser(user) : undefined));
const lostItems = (lostResponse.data ?? []).map((row) => lostFromRow(/** @type {Row} */ (row), user ? profileFromUser(user) : undefined));
const foundItems = (foundResponse.data ?? []).map((row) =>
foundFromRow(row as Row, user ? profileFromUser(user) : undefined)
foundFromRow(/** @type {Row} */ (row), user ? profileFromUser(user) : undefined)
);
return {
@@ -135,23 +171,27 @@ async function listSupabaseReports(event: RequestEvent, user?: App.ArtemisUser |
message: String(row.message),
readAt: row.read_at ? String(row.read_at) : null,
deliveryKey: String(row.delivery_key),
payload: (row.payload as Record<string, unknown>) ?? {},
payload: /** @type {Record<string, unknown>} */ (row.payload) ?? {},
createdAt: String(row.created_at)
}))
};
}
export async function listLostFoundDashboard(event: RequestEvent, user?: App.ArtemisUser | null) {
/**
* @param {import('@sveltejs/kit').RequestEvent} event
* @param {App.ArtemisUser | null} [user]
*/
export async function listLostFoundDashboard(event, user) {
if (!hasSupabaseConfig()) return listMemoryLostFound(user);
return listSupabaseReports(event, user);
}
export async function createLostReport(event: RequestEvent, user: App.ArtemisUser, input: {
id: string;
description: string;
lostAtText: string;
image?: ImageMetadata;
}) {
/**
* @param {import('@sveltejs/kit').RequestEvent} event
* @param {App.ArtemisUser} user
* @param {{ id: string, description: string, lostAtText: string, image?: ImageMetadata }} input
*/
export async function createLostReport(event, user, input) {
if (!hasSupabaseConfig()) return createMemoryLostItem(user, input);
await ensureSupabaseProfile(event, user);
@@ -171,16 +211,15 @@ export async function createLostReport(event: RequestEvent, user: App.ArtemisUse
.single();
if (insertError) throw error(500, `Không thể gửi tín hiệu tìm đồ: ${insertError.message}`);
return { item: lostFromRow(data as Row, profileFromUser(user)), candidates: [] };
return { item: lostFromRow(/** @type {Row} */ (data), profileFromUser(user)), candidates: [] };
}
export async function createFoundReport(event: RequestEvent, user: App.ArtemisUser, input: {
id: string;
description: string;
foundAtText: string;
location: string;
image?: ImageMetadata;
}) {
/**
* @param {import('@sveltejs/kit').RequestEvent} event
* @param {App.ArtemisUser} user
* @param {{ id: string, description: string, foundAtText: string, location: string, image?: ImageMetadata }} input
*/
export async function createFoundReport(event, user, input) {
if (!hasSupabaseConfig()) return createMemoryFoundItem(user, input);
await ensureSupabaseProfile(event, user);
@@ -201,10 +240,11 @@ export async function createFoundReport(event: RequestEvent, user: App.ArtemisUs
.single();
if (insertError) throw error(500, `Không thể gửi tín hiệu trả đồ: ${insertError.message}`);
return { item: foundFromRow(data as Row, profileFromUser(user)), candidates: [] };
return { item: foundFromRow(/** @type {Row} */ (data), profileFromUser(user)), candidates: [] };
}
export async function listLostFoundAdmin(event: RequestEvent) {
/** @param {import('@sveltejs/kit').RequestEvent} event */
export async function listLostFoundAdmin(event) {
const dashboard = await listLostFoundDashboard(event, event.locals.user);
return {
...dashboard,
@@ -214,13 +254,14 @@ export async function listLostFoundAdmin(event: RequestEvent) {
};
}
export async function updateReportStatus(
event: RequestEvent,
actor: App.ArtemisUser,
kind: ReportKind,
id: string,
status: ReportStatus
) {
/**
* @param {import('@sveltejs/kit').RequestEvent} event
* @param {App.ArtemisUser} actor
* @param {ReportKind} kind
* @param {string} id
* @param {ReportStatus} status
*/
export async function updateReportStatus(event, actor, kind, id, status) {
if (!hasSupabaseConfig()) return updateMemoryReportStatus(kind, id, status, actor);
const supabase = hasSupabaseServiceConfig() ? createSupabaseServiceClient() : createSupabaseServerClient(event);
@@ -233,5 +274,5 @@ export async function updateReportStatus(
.single();
if (updateError) throw error(500, `Không thể cập nhật trạng thái tín hiệu: ${updateError.message}`);
return kind === 'lost' ? lostFromRow(data as Row) : foundFromRow(data as Row);
return kind === 'lost' ? lostFromRow(/** @type {Row} */ (data)) : foundFromRow(/** @type {Row} */ (data));
}
@@ -1,4 +1,4 @@
import { error, type RequestEvent } from '@sveltejs/kit';
import { error } from '@sveltejs/kit';
import {
createSupabaseServerClient,
createSupabaseServiceClient,
@@ -12,16 +12,19 @@ import {
updateMemoryListingStatus
} from '$lib/server/persistence/memory-store';
import { rankMarketplaceListings } from '$lib/server/domain/marketplace/ranking';
import type {
ImageMetadata,
ListingStatus,
MarketplaceListing,
ProfileSummary
} from '$lib/shared/types/domain';
type Row = Record<string, unknown>;
/** @typedef {import('$lib/shared/types/domain').ImageMetadata} ImageMetadata */
/** @typedef {import('$lib/shared/types/domain').ListingStatus} ListingStatus */
/** @typedef {import('$lib/shared/types/domain').MarketplaceListing} MarketplaceListing */
/** @typedef {import('$lib/shared/types/domain').ProfileSummary} ProfileSummary */
function profileFromUser(user: App.ArtemisUser): ProfileSummary {
/** @typedef {Record<string, unknown>} Row */
/**
* @param {App.ArtemisUser} user
* @returns {ProfileSummary}
*/
function profileFromUser(user) {
return {
id: user.id,
email: user.email,
@@ -31,8 +34,13 @@ function profileFromUser(user: App.ArtemisUser): ProfileSummary {
};
}
function profileFromRow(row: Row, fallback?: ProfileSummary): ProfileSummary {
const profile = (row.profiles ?? row.profile) as Row | undefined;
/**
* @param {Row} row
* @param {ProfileSummary} [fallback]
* @returns {ProfileSummary}
*/
function profileFromRow(row, fallback) {
const profile = /** @type {Row | undefined} */ (row.profiles ?? row.profile);
return {
id: String(row.owner_profile_id ?? profile?.id ?? fallback?.id ?? 'unknown'),
email: String(profile?.email ?? fallback?.email ?? 'unknown@example.com'),
@@ -42,13 +50,22 @@ function profileFromRow(row: Row, fallback?: ProfileSummary): ProfileSummary {
};
}
function imageFromRow(row: Row): ImageMetadata | undefined {
/**
* @param {Row} row
* @returns {ImageMetadata | undefined}
*/
function imageFromRow(row) {
const metadata = row.image_metadata;
if (!metadata || typeof metadata !== 'object') return undefined;
return metadata as ImageMetadata;
return /** @type {ImageMetadata} */ (metadata);
}
function listingFromRow(row: Row, currentUserId?: string): MarketplaceListing {
/**
* @param {Row} row
* @param {string} [currentUserId]
* @returns {MarketplaceListing}
*/
function listingFromRow(row, currentUserId) {
const interests = Array.isArray(row.marketplace_interests) ? row.marketplace_interests : [];
return {
id: String(row.id),
@@ -58,19 +75,23 @@ function listingFromRow(row: Row, currentUserId?: string): MarketplaceListing {
description: String(row.description ?? ''),
priceText: String(row.price_text ?? ''),
contact: String(row.contact ?? ''),
status: String(row.status ?? 'pending') as ListingStatus,
status: /** @type {ListingStatus} */ (String(row.status ?? 'pending')),
image: imageFromRow(row),
careCount: Number(row.care_count ?? interests.length ?? 0),
caredByCurrentUser: currentUserId
? interests.some((interest) => String((interest as Row).profile_id) === currentUserId)
? interests.some((interest) => String(/** @type {Row} */ (interest).profile_id) === currentUserId)
: false,
payload: (row.payload as Record<string, unknown>) ?? {},
payload: /** @type {Record<string, unknown>} */ (row.payload) ?? {},
createdAt: String(row.created_at ?? new Date().toISOString()),
updatedAt: String(row.updated_at ?? row.created_at ?? new Date().toISOString())
};
}
async function ensureSupabaseProfile(event: RequestEvent, user: App.ArtemisUser) {
/**
* @param {import('@sveltejs/kit').RequestEvent} event
* @param {App.ArtemisUser} user
*/
async function ensureSupabaseProfile(event, user) {
const supabase = createSupabaseServerClient(event);
const { error: profileError } = await supabase.from('profiles').upsert(
{
@@ -87,7 +108,11 @@ async function ensureSupabaseProfile(event: RequestEvent, user: App.ArtemisUser)
if (profileError) throw error(500, `Không thể đồng bộ profile Artemis: ${profileError.message}`);
}
async function listSupabaseListings(event: RequestEvent, user?: App.ArtemisUser | null) {
/**
* @param {import('@sveltejs/kit').RequestEvent} event
* @param {App.ArtemisUser | null} [user]
*/
async function listSupabaseListings(event, user) {
const supabase = createSupabaseServerClient(event);
const { data, error: listError } = await supabase
.from('marketplace_listings')
@@ -96,10 +121,15 @@ async function listSupabaseListings(event: RequestEvent, user?: App.ArtemisUser
.limit(60);
if (listError) return listMemoryMarketplace(user);
return (data ?? []).map((row) => listingFromRow(row as Row, user?.id));
return (data ?? []).map((row) => listingFromRow(/** @type {Row} */ (row), user?.id));
}
export async function listMarketplace(event: RequestEvent, user?: App.ArtemisUser | null, query = '') {
/**
* @param {import('@sveltejs/kit').RequestEvent} event
* @param {App.ArtemisUser | null} [user]
* @param {string} [query]
*/
export async function listMarketplace(event, user, query = '') {
const listings = hasSupabaseConfig() ? await listSupabaseListings(event, user) : listMemoryMarketplace(user);
return {
listings: rankMarketplaceListings(listings, query),
@@ -107,15 +137,12 @@ export async function listMarketplace(event: RequestEvent, user?: App.ArtemisUse
};
}
export async function createMarketplaceListing(event: RequestEvent, user: App.ArtemisUser, input: {
id: string;
name: string;
quantity: number;
description: string;
priceText: string;
contact: string;
image?: ImageMetadata;
}) {
/**
* @param {import('@sveltejs/kit').RequestEvent} event
* @param {App.ArtemisUser} user
* @param {{ id: string, name: string, quantity: number, description: string, priceText: string, contact: string, image?: ImageMetadata }} input
*/
export async function createMarketplaceListing(event, user, input) {
if (!hasSupabaseConfig()) return createMemoryListing(user, input);
await ensureSupabaseProfile(event, user);
@@ -138,10 +165,15 @@ export async function createMarketplaceListing(event: RequestEvent, user: App.Ar
.single();
if (insertError) throw error(500, `Không thể phóng vật phẩm lên chợ: ${insertError.message}`);
return listingFromRow(data as Row, user.id);
return listingFromRow(/** @type {Row} */ (data), user.id);
}
export async function toggleMarketplaceCare(event: RequestEvent, user: App.ArtemisUser, listingId: string) {
/**
* @param {import('@sveltejs/kit').RequestEvent} event
* @param {App.ArtemisUser} user
* @param {string} listingId
*/
export async function toggleMarketplaceCare(event, user, listingId) {
if (!hasSupabaseConfig()) return toggleMemoryMarketplaceInterest(user, listingId);
await ensureSupabaseProfile(event, user);
@@ -172,7 +204,8 @@ export async function toggleMarketplaceCare(event: RequestEvent, user: App.Artem
return null;
}
export async function listMarketplaceAdmin(event: RequestEvent) {
/** @param {import('@sveltejs/kit').RequestEvent} event */
export async function listMarketplaceAdmin(event) {
const listings = hasSupabaseConfig()
? await listSupabaseListings(event, event.locals.user)
: listMemoryMarketplace(event.locals.user);
@@ -184,12 +217,13 @@ export async function listMarketplaceAdmin(event: RequestEvent) {
};
}
export async function moderateMarketplaceListing(
event: RequestEvent,
actor: App.ArtemisUser,
listingId: string,
status: ListingStatus
) {
/**
* @param {import('@sveltejs/kit').RequestEvent} event
* @param {App.ArtemisUser} actor
* @param {string} listingId
* @param {ListingStatus} status
*/
export async function moderateMarketplaceListing(event, actor, listingId, status) {
if (!hasSupabaseConfig()) return updateMemoryListingStatus(listingId, status, actor);
const supabase = hasSupabaseServiceConfig() ? createSupabaseServiceClient() : createSupabaseServerClient(event);
@@ -201,5 +235,5 @@ export async function moderateMarketplaceListing(
.single();
if (updateError) throw error(500, `Không thể cập nhật vật phẩm: ${updateError.message}`);
return listingFromRow(data as Row, actor.id);
return listingFromRow(/** @type {Row} */ (data), actor.id);
}
@@ -1,8 +1,13 @@
import { error, type RequestEvent } from '@sveltejs/kit';
import { error } from '@sveltejs/kit';
import { createSupabaseServerClient, hasSupabaseConfig } from '$lib/server/supabase/client';
import { markMemoryNotificationRead } from '$lib/server/persistence/memory-store';
export async function markNotificationRead(event: RequestEvent, user: App.ArtemisUser, notificationId: string) {
/**
* @param {import('@sveltejs/kit').RequestEvent} event
* @param {App.ArtemisUser} user
* @param {string} notificationId
*/
export async function markNotificationRead(event, user, notificationId) {
if (!hasSupabaseConfig()) return markMemoryNotificationRead(user.id, notificationId);
const supabase = createSupabaseServerClient(event);
@@ -1,6 +1,7 @@
import { error, type RequestEvent } from '@sveltejs/kit';
import { error } from '@sveltejs/kit';
export function assertSameOrigin(event: RequestEvent) {
/** @param {import('@sveltejs/kit').RequestEvent} event */
export function assertSameOrigin(event) {
const origin = event.request.headers.get('origin');
if (!origin) return;
if (origin !== event.url.origin) {
@@ -1,13 +1,16 @@
import { error, type RequestEvent } from '@sveltejs/kit';
import { error } from '@sveltejs/kit';
import {
artemisStorageBucketPrefix,
createSupabaseServerClient,
hasSupabaseConfig
} from '$lib/server/supabase/client';
import { imageUploadLimits } from '$lib/shared/constants/limits';
import type { ImageMetadata, ProductScope } from '$lib/shared/types/domain';
function sanitizeFileName(fileName: string) {
/** @typedef {import('$lib/shared/types/domain').ImageMetadata} ImageMetadata */
/** @typedef {import('$lib/shared/types/domain').ProductScope} ProductScope */
/** @param {string} fileName */
function sanitizeFileName(fileName) {
const fallback = 'artemis-upload';
const safe = fileName
.toLowerCase()
@@ -17,14 +20,16 @@ function sanitizeFileName(fileName: string) {
return safe || fallback;
}
export function bucketForProduct(scope: ProductScope) {
/** @param {ProductScope} scope */
export function bucketForProduct(scope) {
return scope === 'phienchotrenmay'
? `${artemisStorageBucketPrefix}-marketplace-images`
: `${artemisStorageBucketPrefix}-report-images`;
}
export function assertValidImageFile(file: File) {
if (!imageUploadLimits.allowedMimeTypes.includes(file.type as (typeof imageUploadLimits.allowedMimeTypes)[number])) {
/** @param {File} file */
export function assertValidImageFile(file) {
if (!imageUploadLimits.allowedMimeTypes.includes(/** @type {(typeof imageUploadLimits.allowedMimeTypes)[number]} */ (file.type))) {
throw error(400, 'Ảnh cần là JPEG, PNG, WebP hoặc GIF.');
}
@@ -33,19 +38,22 @@ export function assertValidImageFile(file: File) {
}
}
export async function uploadImageFromForm(
event: RequestEvent,
user: App.ArtemisUser,
scope: ProductScope,
recordId: string,
file: FormDataEntryValue | null
): Promise<ImageMetadata | undefined> {
/**
* @param {import('@sveltejs/kit').RequestEvent} event
* @param {App.ArtemisUser} user
* @param {ProductScope} scope
* @param {string} recordId
* @param {FormDataEntryValue | null} file
* @returns {Promise<ImageMetadata | undefined>}
*/
export async function uploadImageFromForm(event, user, scope, recordId, file) {
if (!(file instanceof File) || file.size === 0) return undefined;
assertValidImageFile(file);
const bucket = bucketForProduct(scope);
const path = `${user.id}/${recordId}/${Date.now()}-${sanitizeFileName(file.name)}`;
const metadata: ImageMetadata = {
/** @type {ImageMetadata} */
const metadata = {
path,
bucket,
mimeType: file.type,
@@ -69,7 +77,11 @@ export async function uploadImageFromForm(
return metadata;
}
export async function resolveImageUrl(event: RequestEvent, image?: ImageMetadata) {
/**
* @param {import('@sveltejs/kit').RequestEvent} event
* @param {ImageMetadata} [image]
*/
export async function resolveImageUrl(event, image) {
if (!image) return undefined;
if (image.url) return image.url;
if (!hasSupabaseConfig()) return undefined;
@@ -1,6 +1,5 @@
import { createServerClient, type CookieOptions } from '@supabase/ssr';
import { createServerClient } from '@supabase/ssr';
import { createClient } from '@supabase/supabase-js';
import type { RequestEvent } from '@sveltejs/kit';
import { env as privateEnv } from '$env/dynamic/private';
import { env as publicEnv } from '$env/dynamic/public';
@@ -16,7 +15,8 @@ export function hasSupabaseServiceConfig() {
return Boolean(publicEnv.PUBLIC_SUPABASE_URL && privateEnv.SUPABASE_SERVICE_ROLE_KEY);
}
export function createSupabaseServerClient(event: RequestEvent) {
/** @param {import('@sveltejs/kit').RequestEvent} event */
export function createSupabaseServerClient(event) {
const supabaseUrl = publicEnv.PUBLIC_SUPABASE_URL;
const supabaseAnonKey = publicEnv.PUBLIC_SUPABASE_ANON_KEY;
@@ -27,7 +27,8 @@ export function createSupabaseServerClient(event: RequestEvent) {
return createServerClient(supabaseUrl, supabaseAnonKey, {
cookies: {
getAll: () => event.cookies.getAll(),
setAll: (cookiesToSet: { name: string; value: string; options: CookieOptions }[]) => {
/** @param {{ name: string, value: string, options: import('@supabase/ssr').CookieOptions }[]} cookiesToSet */
setAll: (cookiesToSet) => {
cookiesToSet.forEach(({ name, value, options }) => {
event.cookies.set(name, value, { ...options, path: options.path ?? '/' });
});
@@ -1,10 +1,10 @@
export const appName = 'Artemis';
export const publicRoutes = [
export const publicRoutes = /** @type {const} */ ([
{ href: '/', label: 'Artemis' },
{ href: '/vutrudodac', label: 'Vũ trụ đồ đạc' },
{ href: '/phienchotrenmay', label: 'Phiên chợ trên mây' },
{ href: '/account', label: 'Account' }
] as const;
]);
export const footerAttribution = 'Made by miti99 (miti99.com) from artemis (iamminhnguyet.com) idea, with <3';
@@ -1,18 +1,18 @@
export const imageUploadLimits = {
export const imageUploadLimits = /** @type {const} */ ({
maxBytes: 2 * 1024 * 1024,
maxImagesPerRecord: 1,
signedUrlTtlSeconds: 60 * 10,
allowedMimeTypes: ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
} as const;
});
export const freshnessTargets = {
export const freshnessTargets = /** @type {const} */ ({
notificationPollMs: 15000,
adminQueuePollMs: 15000,
maxAcceptedFreshnessMs: 30000
} as const;
});
export const abuseLimits = {
export const abuseLimits = /** @type {const} */ ({
reportsPerUserPerDay: 20,
listingsPerUserPerDay: 10,
interestsPerUserPerDay: 60
} as const;
});
@@ -1,8 +1,8 @@
import type { LayoutServerLoad } from './$types';
import { hasAdminScope } from '$lib/server/auth/admin-roles';
import { hasSupabaseConfig } from '$lib/server/supabase/client';
export const load: LayoutServerLoad = ({ locals, url }) => {
/** @type {import('./$types').LayoutServerLoad} */
export const load = ({ locals, url }) => {
return {
currentPath: url.pathname,
user: locals.user,
+3 -3
View File
@@ -1,9 +1,9 @@
<script lang="ts">
<script>
import { appName, publicRoutes } from '$lib/shared/constants/app';
import '$lib/ui/artemis-theme/artemis.css';
import type { LayoutData } from './$types';
export let data: LayoutData;
/** @type {import('./$types').LayoutData} */
export let data;
</script>
<div class="app-shell">
@@ -1,8 +1,8 @@
import { redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { createSupabaseServerClient, hasSupabaseConfig } from '$lib/server/supabase/client';
export const load: PageServerLoad = ({ locals, url }) => {
/** @type {import('./$types').PageServerLoad} */
export const load = ({ locals, url }) => {
return {
user: locals.user,
reason: url.searchParams.get('reason'),
@@ -10,7 +10,8 @@ export const load: PageServerLoad = ({ locals, url }) => {
};
};
export const actions: Actions = {
/** @type {import('./$types').Actions} */
export const actions = {
signIn: async (event) => {
if (!hasSupabaseConfig()) {
throw redirect(303, '/account');
+5 -5
View File
@@ -1,8 +1,8 @@
<script lang="ts">
import type { ActionData, PageData } from './$types';
export let data: PageData;
export let form: ActionData;
<script>
/** @type {import('./$types').PageData} */
export let data;
/** @type {import('./$types').ActionData} */
export let form;
</script>
<svelte:head>
+21
View File
@@ -0,0 +1,21 @@
import { json } from '@sveltejs/kit';
const retired = () =>
json(
{
status: 'retired',
message: 'Legacy /api/* routes were retired in the SvelteKit/Supabase cutover. Use page actions instead.'
},
{ status: 410 }
);
/** @type {import('./$types').RequestHandler} */
export const GET = retired;
/** @type {import('./$types').RequestHandler} */
export const POST = retired;
/** @type {import('./$types').RequestHandler} */
export const PATCH = retired;
/** @type {import('./$types').RequestHandler} */
export const PUT = retired;
/** @type {import('./$types').RequestHandler} */
export const DELETE = retired;
-16
View File
@@ -1,16 +0,0 @@
import { json, type RequestHandler } from '@sveltejs/kit';
const retired = () =>
json(
{
status: 'retired',
message: 'Legacy /api/* routes were retired in the SvelteKit/Supabase cutover. Use page actions instead.'
},
{ status: 410 }
);
export const GET: RequestHandler = retired;
export const POST: RequestHandler = retired;
export const PATCH: RequestHandler = retired;
export const PUT: RequestHandler = retired;
export const DELETE: RequestHandler = retired;
@@ -1,7 +1,9 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { json } from '@sveltejs/kit';
export const POST: RequestHandler = async ({ request }) => {
let payload: { message?: unknown } = {};
/** @type {import('./$types').RequestHandler} */
export const POST = async ({ request }) => {
/** @type {{ message?: unknown }} */
let payload = {};
try {
payload = await request.json();
@@ -1,5 +1,4 @@
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { requireVerifiedUserForAction } from '$lib/server/auth/session';
import { assertSameOrigin } from '$lib/server/security/origin-check';
import {
@@ -9,11 +8,16 @@ import {
} from '$lib/server/repositories/marketplace';
import { uploadImageFromForm } from '$lib/server/storage/image-storage';
function readText(formData: FormData, key: string) {
/**
* @param {FormData} formData
* @param {string} key
*/
function readText(formData, key) {
return String(formData.get(key) ?? '').trim();
}
export const load: PageServerLoad = async (event) => {
/** @type {import('./$types').PageServerLoad} */
export const load = async (event) => {
const query = event.url.searchParams.get('q')?.trim() ?? '';
return {
...(await listMarketplace(event, event.locals.user, query)),
@@ -22,7 +26,8 @@ export const load: PageServerLoad = async (event) => {
};
};
export const actions: Actions = {
/** @type {import('./$types').Actions} */
export const actions = {
createListing: async (event) => {
assertSameOrigin(event);
const authFailure = requireVerifiedUserForAction(event.locals.user);
+5 -5
View File
@@ -1,8 +1,8 @@
<script lang="ts">
import type { ActionData, PageData } from './$types';
export let data: PageData;
export let form: ActionData;
<script>
/** @type {import('./$types').PageData} */
export let data;
/** @type {import('./$types').ActionData} */
export let form;
</script>
<svelte:head>
@@ -1,28 +1,35 @@
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { requireAdmin } from '$lib/server/auth/admin-roles';
import { assertSameOrigin } from '$lib/server/security/origin-check';
import { listMarketplaceAdmin, moderateMarketplaceListing } from '$lib/server/repositories/marketplace';
import type { ListingStatus } from '$lib/shared/types/domain';
const allowedStatuses = new Set<ListingStatus>(['pending', 'approved', 'rejected', 'hidden', 'passed']);
/** @typedef {import('$lib/shared/types/domain').ListingStatus} ListingStatus */
function readText(formData: FormData, key: string) {
/** @type {Set<ListingStatus>} */
const allowedStatuses = new Set(['pending', 'approved', 'rejected', 'hidden', 'passed']);
/**
* @param {FormData} formData
* @param {string} key
*/
function readText(formData, key) {
return String(formData.get(key) ?? '').trim();
}
export const load: PageServerLoad = async (event) => {
/** @type {import('./$types').PageServerLoad} */
export const load = async (event) => {
await requireAdmin(event, 'phienchotrenmay');
return listMarketplaceAdmin(event);
};
export const actions: Actions = {
/** @type {import('./$types').Actions} */
export const actions = {
setStatus: async (event) => {
assertSameOrigin(event);
const actor = await requireAdmin(event, 'phienchotrenmay');
const formData = await event.request.formData();
const id = readText(formData, 'id');
const status = readText(formData, 'status') as ListingStatus;
const status = /** @type {ListingStatus} */ (readText(formData, 'status'));
if (!id || !allowedStatuses.has(status)) return fail(400, { message: 'Vật phẩm admin không hợp lệ.' });
await moderateMarketplaceListing(event, actor, id, status);
@@ -1,8 +1,8 @@
<script lang="ts">
import type { ActionData, PageData } from './$types';
export let data: PageData;
export let form: ActionData;
<script>
/** @type {import('./$types').PageData} */
export let data;
/** @type {import('./$types').ActionData} */
export let form;
</script>
<svelte:head>
@@ -1,29 +1,35 @@
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { requireVerifiedUserForAction } from '$lib/server/auth/session';
import { assertSameOrigin } from '$lib/server/security/origin-check';
import { createFoundReport, createLostReport, listLostFoundDashboard } from '$lib/server/repositories/lost-found';
import { markNotificationRead } from '$lib/server/repositories/notifications';
import { uploadImageFromForm } from '$lib/server/storage/image-storage';
function readText(formData: FormData, key: string) {
/**
* @param {FormData} formData
* @param {string} key
*/
function readText(formData, key) {
return String(formData.get(key) ?? '').trim();
}
function validateDescription(description: string) {
/** @param {string} description */
function validateDescription(description) {
if (description.length < 3) return 'Mô tả cần rõ hơn để radar dò đúng tín hiệu.';
if (description.length > 2000) return 'Mô tả đang quá dài cho một tín hiệu Artemis.';
return null;
}
export const load: PageServerLoad = async (event) => {
/** @type {import('./$types').PageServerLoad} */
export const load = async (event) => {
return {
...(await listLostFoundDashboard(event, event.locals.user)),
user: event.locals.user
};
};
export const actions: Actions = {
/** @type {import('./$types').Actions} */
export const actions = {
createLost: async (event) => {
assertSameOrigin(event);
const authFailure = requireVerifiedUserForAction(event.locals.user);
+5 -5
View File
@@ -1,8 +1,8 @@
<script lang="ts">
import type { ActionData, PageData } from './$types';
export let data: PageData;
export let form: ActionData;
<script>
/** @type {import('./$types').PageData} */
export let data;
/** @type {import('./$types').ActionData} */
export let form;
</script>
<svelte:head>
@@ -1,29 +1,36 @@
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { requireAdmin } from '$lib/server/auth/admin-roles';
import { assertSameOrigin } from '$lib/server/security/origin-check';
import { listLostFoundAdmin, updateReportStatus } from '$lib/server/repositories/lost-found';
import type { ReportStatus } from '$lib/shared/types/domain';
const allowedStatuses = new Set<ReportStatus>(['open', 'matched', 'returned', 'closed', 'hidden']);
/** @typedef {import('$lib/shared/types/domain').ReportStatus} ReportStatus */
function readText(formData: FormData, key: string) {
/** @type {Set<ReportStatus>} */
const allowedStatuses = new Set(['open', 'matched', 'returned', 'closed', 'hidden']);
/**
* @param {FormData} formData
* @param {string} key
*/
function readText(formData, key) {
return String(formData.get(key) ?? '').trim();
}
export const load: PageServerLoad = async (event) => {
/** @type {import('./$types').PageServerLoad} */
export const load = async (event) => {
await requireAdmin(event, 'vutrudodac');
return listLostFoundAdmin(event);
};
export const actions: Actions = {
/** @type {import('./$types').Actions} */
export const actions = {
setStatus: async (event) => {
assertSameOrigin(event);
const actor = await requireAdmin(event, 'vutrudodac');
const formData = await event.request.formData();
const id = readText(formData, 'id');
const kind = readText(formData, 'kind');
const status = readText(formData, 'status') as ReportStatus;
const status = /** @type {ReportStatus} */ (readText(formData, 'status'));
if (!id || (kind !== 'lost' && kind !== 'found') || !allowedStatuses.has(status)) {
return fail(400, { message: 'Tín hiệu admin không hợp lệ.' });
+5 -5
View File
@@ -1,8 +1,8 @@
<script lang="ts">
import type { ActionData, PageData } from './$types';
export let data: PageData;
export let form: ActionData;
<script>
/** @type {import('./$types').PageData} */
export let data;
/** @type {import('./$types').ActionData} */
export let form;
</script>
<svelte:head>
View File