You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

341 lines
10 KiB

import {
bool,
clearSessionCookie,
fail,
getSessionToken,
int,
ok,
optionalText,
requireString,
sessionCookie,
stringArray,
text
} from './http.mjs';
import {
createRecord,
fileUrl,
firstRecord,
getRecord,
listRecords,
pbPublic,
pbUser,
updateRecord,
updateRecordForm
} from './pocketbase.mjs';
export const ROLES = ['user', 'limited', 'moderator', 'admin'];
export const STATUSES = ['active', 'limited', 'blocked', 'deleted'];
export const INTENTS = ['dating', 'friends', 'long_term', 'casual', 'unsure'];
export const VISIBILITIES = ['visible', 'hidden', 'paused'];
export const LIKE_STATES = ['like', 'pass'];
export const REPORT_REASONS = ['fake', 'abuse', 'spam', 'harassment', 'underage', 'other'];
export const REPORT_PRIORITIES = ['low', 'normal', 'high', 'urgent'];
export const MODERATION_ACTIONS = ['dismiss', 'warn', 'restrict', 'hide_profile', 'ban_demo_user'];
export async function authenticate(identity, password) {
return pbPublic('/api/collections/dating_users/auth-with-password', {
method: 'POST',
body: { identity, password }
});
}
export async function registerUser(input) {
const email = requireString(input.email, 'email', 200).toLowerCase();
const password = requireString(input.password, 'password');
const passwordConfirm = text(input.passwordConfirm || input.confirmPassword || input.password);
const displayName = requireString(input.displayName, 'displayName', 80);
if (password.length < 8) fail(400, 'weak_password', 'Password must contain at least 8 characters.');
if (password !== passwordConfirm) fail(400, 'password_mismatch', 'Passwords do not match.');
if (!bool(input.adultConfirmed) && !bool(input.adultVerified)) {
fail(400, 'adult_confirmation_required', 'Adult confirmation is required.');
}
await createRecord('dating_users', {
email,
password,
passwordConfirm,
displayName,
role: 'user',
status: 'active',
adultVerified: true,
emailVisibility: false,
verified: true
});
return authenticate(email, password);
}
export async function currentSession(request) {
const token = getSessionToken(request);
if (!token) return null;
try {
const auth = await pbUser('/api/collections/dating_users/auth-refresh', token, {
method: 'POST'
});
return {
token: auth.token,
user: userView(auth.record),
cookie: sessionCookie(auth.token)
};
} catch {
return {
expired: true,
cookie: clearSessionCookie()
};
}
}
export async function requireSession(request) {
const session = await currentSession(request);
if (!session || session.expired) fail(401, 'session_required', 'Authentication required.');
if (session.user.status === 'deleted') fail(403, 'account_deleted', 'Account is deleted.');
return session;
}
export function withSession(response, session) {
if (session?.cookie) {
return { ...response, cookies: [...(response.cookies || []), session.cookie] };
}
return response;
}
export function userView(record) {
return {
id: record.id,
email: record.email,
displayName: record.displayName || record.name || '',
role: ROLES.includes(record.role) ? record.role : 'user',
status: STATUSES.includes(record.status) ? record.status : 'active',
adultVerified: Boolean(record.adultVerified),
verified: Boolean(record.verified),
created: record.created,
updated: record.updated,
lastLoginAt: record.lastLoginAt || ''
};
}
export function profileView(record) {
if (!record) return null;
const photos = Array.isArray(record.photos) ? record.photos : record.photos ? [record.photos] : [];
return {
id: record.id,
userId: relationId(record.user),
displayName: record.displayName || '',
age: Number(record.age || 0),
bio: record.bio || '',
interests: Array.isArray(record.interests) ? record.interests : [],
intent: record.intent || 'unsure',
approxLocation: record.approxLocation || '',
visibility: record.visibility || 'hidden',
photos,
primaryPhoto: record.primaryPhoto || photos[0] || '',
photoUrls: photos.map((filename) => ({
filename,
original: fileUrl('dating_profiles', record.id, filename),
thumb: fileUrl('dating_profiles', record.id, filename, '120x120'),
card: fileUrl('dating_profiles', record.id, filename, '400x600')
})),
completed: Boolean(record.completed),
publishedAt: record.publishedAt || '',
created: record.created,
updated: record.updated
};
}
export function matchView(record, profiles = []) {
return {
id: record.id,
userIds: relationIds(record.users),
state: record.state || 'active',
expiresAt: record.expiresAt || '',
metadata: record.metadata || {},
profiles,
created: record.created,
updated: record.updated
};
}
export function messageView(record) {
return {
id: record.id,
matchId: relationId(record.match),
senderId: relationId(record.sender),
body: record.body || '',
state: record.state || 'sent',
clientNonce: record.clientNonce || '',
deliveredAt: record.deliveredAt || '',
metadata: record.metadata || {},
created: record.created,
updated: record.updated
};
}
export function reportView(record) {
return {
id: record.id,
reporterId: relationId(record.reporter),
targetUserId: relationId(record.targetUser),
targetMessageId: relationId(record.targetMessage),
reason: record.reason,
details: record.details || '',
state: record.state || 'open',
priority: record.priority || 'normal',
resolvedAt: record.resolvedAt || '',
resolverId: relationId(record.resolver),
created: record.created,
updated: record.updated
};
}
export async function getProfileByUser(userId) {
return firstRecord('dating_profiles', `user = "${escapeFilter(userId)}"`);
}
export async function requireProfile(userId) {
const profile = await getProfileByUser(userId);
if (!profile) fail(409, 'profile_required', 'Profile must exist before this operation.');
return profile;
}
export async function upsertProfile(user, input) {
checkPermission(user, 'profile:update:self');
const existing = await getProfileByUser(user.id);
const payload = profilePayload(input, user, existing);
if (existing) return updateRecord('dating_profiles', existing.id, payload);
return createRecord('dating_profiles', { ...payload, user: user.id });
}
export function profilePayload(input, user, existing) {
const displayName = optionalText(input.displayName, 80) || existing?.displayName || user.displayName;
if (!displayName) fail(400, 'missing_display_name', 'displayName is required.');
const age = int(input.age ?? existing?.age, 0, { min: 18, max: 120 });
if (!age) fail(400, 'invalid_age', 'age must be an integer between 18 and 120.');
const intent = enumValue(input.intent || existing?.intent || 'unsure', INTENTS, 'intent');
const visibility = enumValue(input.visibility || existing?.visibility || 'hidden', VISIBILITIES, 'visibility');
const completed = bool(input.completed, Boolean(existing?.completed));
return {
displayName,
age,
bio: optionalText(input.bio, 500),
interests: stringArray(input.interests, 30, 60),
intent,
approxLocation: optionalText(input.approxLocation, 120),
visibility,
completed,
publishedAt: completed && visibility === 'visible' ? new Date().toISOString() : existing?.publishedAt || ''
};
}
export function checkPermission(user, action) {
if (!user) fail(401, 'session_required', 'Authentication required.');
if (user.role === 'admin') return true;
if (user.status === 'blocked' || user.status === 'deleted') {
fail(403, 'account_restricted', 'Account cannot perform this action.');
}
if (action.startsWith('moderation:') || action === 'profile:photo:moderate') {
if (user.role === 'moderator') return true;
fail(403, 'permission_denied', 'Permission denied.');
}
if (user.role === 'moderator') {
if (['discover:view', 'safety:block', 'safety:report', 'devtools:view'].includes(action)) return true;
if (action.startsWith('profile:')) return true;
}
if (user.status === 'limited' || user.role === 'limited') {
if (['profile:create', 'profile:update:self', 'profile:photo:delete:self'].includes(action)) return true;
if (['safety:block', 'safety:report', 'devtools:view'].includes(action)) return true;
fail(403, 'permission_denied', 'Permission denied.');
}
if (
[
'profile:create',
'profile:update:self',
'profile:photo:add',
'profile:photo:delete:self',
'profile:photo:reorder',
'discover:view',
'match:like',
'chat:send',
'safety:block',
'safety:report',
'devtools:view'
].includes(action)
) {
return true;
}
fail(403, 'permission_denied', 'Permission denied.');
}
export async function assertMatchMember(matchId, userId) {
const record = await getRecord('dating_matches', matchId);
const ids = relationIds(record.users);
if (!ids.includes(userId)) fail(404, 'match_not_found', 'Match not found.');
return record;
}
export async function createAudit(event, actorId, data = {}) {
try {
await createRecord('dating_audit_events', {
actor: actorId || '',
targetUser: data.targetUser || '',
report: data.report || '',
event,
module: data.module || 'dating',
traceId: data.traceId || '',
data: data.data || {}
});
} catch {
// Audit must not break the user flow in the local demo server.
}
}
export function relationId(value) {
if (Array.isArray(value)) return String(value[0] || '');
return value ? String(value) : '';
}
export function relationIds(value) {
if (!Array.isArray(value)) return value ? [String(value)] : [];
return value.map(String);
}
export function escapeFilter(value) {
return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
export function enumValue(value, allowed, field) {
const next = text(value);
if (!allowed.includes(next)) fail(400, 'invalid_enum', `${field} has an invalid value.`, { field, allowed });
return next;
}
export function sessionPayload(session) {
return {
authenticated: true,
user: session.user
};
}
export function sessionResponse(session) {
return withSession(ok(sessionPayload(session)), session);
}
export async function listProfilesByUserIds(userIds) {
const profiles = [];
for (const userId of userIds) {
const profile = await getProfileByUser(userId);
if (profile) profiles.push(profileView(profile));
}
return profiles;
}
export async function allRecords(collection, options = {}) {
const perPage = options.perPage || 100;
const first = await listRecords(collection, { ...options, page: 1, perPage });
const items = [...(first.items || [])];
const totalPages = first.totalPages || 1;
for (let page = 2; page <= totalPages; page += 1) {
const next = await listRecords(collection, { ...options, page, perPage });
items.push(...(next.items || []));
}
return items;
}

Powered by TurnKey Linux.