mirror of
https://github.com/tiennm99/artemis-demo-agent.git
synced 2026-09-05 00:17:50 +00:00
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:
@@ -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
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
+53
-22
@@ -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;
|
||||
+16
-6
@@ -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) }))
|
||||
+7
-2
@@ -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,
|
||||
+111
-86
@@ -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,
|
||||
+2
-1
@@ -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', () => {
|
||||
+86
-45
@@ -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));
|
||||
}
|
||||
+74
-40
@@ -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);
|
||||
}
|
||||
+7
-2
@@ -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,
|
||||
@@ -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');
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
+9
-4
@@ -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);
|
||||
@@ -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>
|
||||
|
||||
+14
-7
@@ -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);
|
||||
@@ -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>
|
||||
|
||||
+14
-7
@@ -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ệ.' });
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user