feat: expand profiles and weapon configuration
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
import { AVATAR_PRESETS } from '$lib/server/avatars';
|
||||
import { emailDeliveryConfigured } from '$lib/server/auth/mail';
|
||||
import { canRemoveAuthenticationMethod, getAuthenticationMethods } from '$lib/server/auth/methods';
|
||||
import type { Database } from '@tursodatabase/database';
|
||||
|
||||
export async function loadAccountControlData(
|
||||
database: Database,
|
||||
userId: string,
|
||||
requestedPanel: string | null = null
|
||||
) {
|
||||
const [connection, emailIdentity, passkeys, authenticationMethods, notifications, notificationUnread] =
|
||||
await Promise.all([
|
||||
(await database.prepare(`SELECT users.discord_id,users.login_username,users.password_hash,
|
||||
discord_profiles.username AS discord_username,discord_profiles.display_name,
|
||||
discord_profiles.avatar_hash
|
||||
FROM users
|
||||
LEFT JOIN discord_profiles ON discord_profiles.user_id=users.id
|
||||
WHERE users.id=?`)).get(userId),
|
||||
(await database.prepare('SELECT email,verified_at FROM user_emails WHERE user_id=?')).get(userId),
|
||||
(await database.prepare(`SELECT id,name,device_type,backed_up,last_used_at,created_at
|
||||
FROM passkeys WHERE user_id=? ORDER BY created_at DESC`)).all(userId),
|
||||
getAuthenticationMethods(database, userId),
|
||||
(await database.prepare(`SELECT n.id,n.category,n.source,n.title,n.body,n.link,n.mandatory,
|
||||
n.created_at,n.read_at
|
||||
FROM notifications n
|
||||
LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category
|
||||
WHERE n.user_id=? AND n.read_at IS NULL
|
||||
AND (n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)
|
||||
ORDER BY n.created_at DESC LIMIT 5`)).all(userId),
|
||||
(await database.prepare(`SELECT COUNT(*) AS count FROM notifications n
|
||||
LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category
|
||||
WHERE n.user_id=? AND n.read_at IS NULL
|
||||
AND (n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)`)).get(userId)
|
||||
]);
|
||||
return {
|
||||
initialPanel: ['identity', 'access', 'account'].includes(String(requestedPanel))
|
||||
? String(requestedPanel)
|
||||
: 'identity',
|
||||
loginUsername: connection?.login_username ? String(connection.login_username) : null,
|
||||
hasLocalCredentials: typeof connection?.password_hash === 'string'
|
||||
&& typeof connection?.login_username === 'string',
|
||||
discordConnection: connection?.discord_id ? {
|
||||
id: String(connection.discord_id),
|
||||
username: connection.discord_username ? String(connection.discord_username) : null,
|
||||
displayName: connection.display_name ? String(connection.display_name) : null,
|
||||
avatarHash: connection.avatar_hash ? String(connection.avatar_hash) : null,
|
||||
canUnlink: canRemoveAuthenticationMethod(authenticationMethods, 'discord')
|
||||
} : null,
|
||||
emailIdentity: emailIdentity ? {
|
||||
email: String(emailIdentity.email),
|
||||
verifiedAt: String(emailIdentity.verified_at)
|
||||
} : null,
|
||||
passkeys: passkeys.map((passkey) => ({
|
||||
id: String(passkey.id),
|
||||
name: String(passkey.name),
|
||||
deviceType: String(passkey.device_type),
|
||||
backedUp: Boolean(passkey.backed_up),
|
||||
lastUsedAt: passkey.last_used_at ? String(passkey.last_used_at) : null,
|
||||
createdAt: String(passkey.created_at)
|
||||
})),
|
||||
authenticationMethods,
|
||||
emailDeliveryConfigured: emailDeliveryConfigured(),
|
||||
avatarPresets: AVATAR_PRESETS,
|
||||
notifications,
|
||||
notificationUnread: Number(notificationUnread?.count ?? 0)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { agentProfileHref } from '$lib/agents';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { createNotification } from '$lib/server/notifications';
|
||||
import {
|
||||
profileCommentFollowerRecipients,
|
||||
profileCommentRootId
|
||||
} from '$lib/server/profile-comment-thread';
|
||||
|
||||
type CommentTreeRow = {
|
||||
id: unknown;
|
||||
parent_id: unknown;
|
||||
};
|
||||
|
||||
export async function profileCommentConversation(
|
||||
commentId: string,
|
||||
profileOwnerId: string
|
||||
): Promise<{ rootId: string; rows: Array<{ id: string; parentId: string | null }> } | null> {
|
||||
const database = await getDatabase();
|
||||
const selected = await (await database.prepare(`SELECT comments.id,comments.thread_id
|
||||
FROM comments
|
||||
JOIN comment_threads thread ON thread.id=comments.thread_id
|
||||
WHERE comments.id=? AND comments.status='visible'
|
||||
AND thread.target_type='profile' AND thread.target_id=?`))
|
||||
.get(commentId, profileOwnerId);
|
||||
if (!selected?.thread_id) return null;
|
||||
const records = await (await database.prepare(
|
||||
`SELECT id,parent_id FROM comments WHERE thread_id=? AND status IN ('visible','removed')`
|
||||
)).all(selected.thread_id) as CommentTreeRow[];
|
||||
const rows = records.map((row) => ({
|
||||
id: String(row.id),
|
||||
parentId: row.parent_id ? String(row.parent_id) : null
|
||||
}));
|
||||
const rootId = profileCommentRootId(rows, commentId);
|
||||
return rootId ? { rootId, rows } : null;
|
||||
}
|
||||
|
||||
export async function notifyProfileCommentFollowers(input: {
|
||||
commentId: string;
|
||||
authorId: string;
|
||||
authorDisplayName: string;
|
||||
profileOwnerId: string;
|
||||
profileOwnerDisplayName: string;
|
||||
profileOwnerPublicSlug: string | null;
|
||||
directRecipientId: string | null;
|
||||
}) {
|
||||
const conversation = await profileCommentConversation(input.commentId, input.profileOwnerId);
|
||||
if (!conversation) return;
|
||||
const database = await getDatabase();
|
||||
const followerRows = await (await database.prepare(
|
||||
'SELECT user_id FROM comment_thread_follows WHERE root_comment_id=?'
|
||||
)).all(conversation.rootId);
|
||||
const recipients = profileCommentFollowerRecipients(
|
||||
input.authorId,
|
||||
input.directRecipientId,
|
||||
followerRows.map((row) => String(row.user_id))
|
||||
);
|
||||
const link = agentProfileHref({
|
||||
id: input.profileOwnerId,
|
||||
publicSlug: input.profileOwnerPublicSlug
|
||||
}, `profile-comment-${input.commentId}`);
|
||||
for (const userId of recipients) {
|
||||
await createNotification({
|
||||
userId,
|
||||
category: 'comments',
|
||||
source: 'agent',
|
||||
title: 'New reply in a followed conversation',
|
||||
body: `${input.authorDisplayName} replied in a conversation you follow on ${input.profileOwnerDisplayName}'s Agent profile.`,
|
||||
link,
|
||||
actions: [{ label: 'VIEW REPLY', href: link }]
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
orderProfileCommentThread,
|
||||
profileCommentFollowerRecipients,
|
||||
profileCommentRootId,
|
||||
profileCommentNotificationRecipient
|
||||
} from './profile-comment-thread';
|
||||
|
||||
const row = (
|
||||
id: string,
|
||||
parentId: string | null,
|
||||
createdAt: string,
|
||||
status: 'visible' | 'removed' = 'visible'
|
||||
) => ({ id, parentId, createdAt, status, label: id });
|
||||
|
||||
describe('profile comment threading', () => {
|
||||
it('shows newest conversations first and replies in reading order', () => {
|
||||
const ordered = orderProfileCommentThread([
|
||||
row('older-root', null, '2026-01-01T00:00:00.000Z'),
|
||||
row('newer-root', null, '2026-01-03T00:00:00.000Z'),
|
||||
row('later-reply', 'newer-root', '2026-01-03T02:00:00.000Z'),
|
||||
row('first-reply', 'newer-root', '2026-01-03T01:00:00.000Z'),
|
||||
row('nested-reply', 'first-reply', '2026-01-03T01:30:00.000Z')
|
||||
]);
|
||||
|
||||
expect(ordered.map((comment) => [comment.id, comment.depth])).toEqual([
|
||||
['newer-root', 0],
|
||||
['first-reply', 1],
|
||||
['nested-reply', 2],
|
||||
['later-reply', 1],
|
||||
['older-root', 0]
|
||||
]);
|
||||
expect(ordered.find((comment) => comment.id === 'newer-root')?.hasReplies).toBe(true);
|
||||
expect(ordered.find((comment) => comment.id === 'nested-reply')?.rootId).toBe('newer-root');
|
||||
});
|
||||
|
||||
it('keeps removed ancestors only when they preserve a visible conversation', () => {
|
||||
const ordered = orderProfileCommentThread([
|
||||
row('removed-parent', null, '2026-01-01T00:00:00.000Z', 'removed'),
|
||||
row('visible-reply', 'removed-parent', '2026-01-02T00:00:00.000Z'),
|
||||
row('removed-leaf', null, '2026-01-03T00:00:00.000Z', 'removed')
|
||||
]);
|
||||
|
||||
expect(ordered.map((comment) => comment.id)).toEqual(['removed-parent', 'visible-reply']);
|
||||
});
|
||||
|
||||
it('recovers visible orphaned and cyclic rows as top-level conversations', () => {
|
||||
const ordered = orderProfileCommentThread([
|
||||
row('orphan', 'missing', '2026-01-03T00:00:00.000Z'),
|
||||
row('cycle-a', 'cycle-b', '2026-01-01T00:00:00.000Z'),
|
||||
row('cycle-b', 'cycle-a', '2026-01-02T00:00:00.000Z')
|
||||
]);
|
||||
|
||||
expect(new Set(ordered.map((comment) => comment.id))).toEqual(
|
||||
new Set(['orphan', 'cycle-a', 'cycle-b'])
|
||||
);
|
||||
expect(ordered.every((comment) => Number.isFinite(comment.depth))).toBe(true);
|
||||
});
|
||||
|
||||
it('notifies the direct reply recipient without sending self-notifications', () => {
|
||||
expect(profileCommentNotificationRecipient('author', 'parent-author', 'owner'))
|
||||
.toBe('parent-author');
|
||||
expect(profileCommentNotificationRecipient('author', 'author', 'owner'))
|
||||
.toBe('owner');
|
||||
expect(profileCommentNotificationRecipient('owner', null, 'owner'))
|
||||
.toBeNull();
|
||||
});
|
||||
|
||||
it('resolves a nested reply to its top-level conversation', () => {
|
||||
const rows = [
|
||||
row('root', null, '2026-01-01T00:00:00.000Z'),
|
||||
row('reply', 'root', '2026-01-02T00:00:00.000Z'),
|
||||
row('nested', 'reply', '2026-01-03T00:00:00.000Z')
|
||||
];
|
||||
expect(profileCommentRootId(rows, 'nested')).toBe('root');
|
||||
expect(profileCommentRootId(rows, 'missing')).toBeNull();
|
||||
});
|
||||
|
||||
it('deduplicates followers and excludes the author and direct recipient', () => {
|
||||
expect(profileCommentFollowerRecipients(
|
||||
'author',
|
||||
'direct',
|
||||
['follower', 'direct', 'author', 'follower', 'second']
|
||||
)).toEqual(['follower', 'second']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
export type ProfileCommentThreadRow = {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
status: 'visible' | 'removed';
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type ThreadedProfileComment<T extends ProfileCommentThreadRow> = T & {
|
||||
depth: number;
|
||||
hasReplies: boolean;
|
||||
rootId: string;
|
||||
};
|
||||
|
||||
const timestamp = (value: string) => {
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
};
|
||||
|
||||
export function orderProfileCommentThread<T extends ProfileCommentThreadRow>(
|
||||
rows: T[]
|
||||
): Array<ThreadedProfileComment<T>> {
|
||||
const nodes = new Map(rows.map((row) => [
|
||||
row.id,
|
||||
{ row, children: [] as T[] }
|
||||
]));
|
||||
const roots: T[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const parent = row.parentId && row.parentId !== row.id
|
||||
? nodes.get(row.parentId)
|
||||
: null;
|
||||
if (parent) parent.children.push(row);
|
||||
else roots.push(row);
|
||||
}
|
||||
|
||||
const renderable = new Map<string, boolean>();
|
||||
const visibilityStack = new Set<string>();
|
||||
const hasVisibleContent = (row: T): boolean => {
|
||||
const cached = renderable.get(row.id);
|
||||
if (cached !== undefined) return cached;
|
||||
if (visibilityStack.has(row.id)) return row.status === 'visible';
|
||||
visibilityStack.add(row.id);
|
||||
const result = row.status === 'visible'
|
||||
|| Boolean(nodes.get(row.id)?.children.some(hasVisibleContent));
|
||||
visibilityStack.delete(row.id);
|
||||
renderable.set(row.id, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
const byNewest = (left: T, right: T) =>
|
||||
timestamp(right.createdAt) - timestamp(left.createdAt) || right.id.localeCompare(left.id);
|
||||
const byOldest = (left: T, right: T) =>
|
||||
timestamp(left.createdAt) - timestamp(right.createdAt) || left.id.localeCompare(right.id);
|
||||
roots.sort(byNewest);
|
||||
for (const node of nodes.values()) node.children.sort(byOldest);
|
||||
|
||||
const ordered: Array<ThreadedProfileComment<T>> = [];
|
||||
const visited = new Set<string>();
|
||||
const append = (row: T, depth: number, rootId: string) => {
|
||||
if (visited.has(row.id) || !hasVisibleContent(row)) return;
|
||||
visited.add(row.id);
|
||||
const children = (nodes.get(row.id)?.children ?? []).filter(hasVisibleContent);
|
||||
ordered.push({ ...row, depth, hasReplies: children.length > 0, rootId });
|
||||
for (const child of children) append(child, depth + 1, rootId);
|
||||
};
|
||||
|
||||
for (const root of roots) append(root, 0, root.id);
|
||||
for (const row of [...rows].sort(byNewest)) append(row, 0, row.id);
|
||||
return ordered;
|
||||
}
|
||||
|
||||
export function profileCommentRootId(
|
||||
rows: Array<Pick<ProfileCommentThreadRow, 'id' | 'parentId'>>,
|
||||
commentId: string
|
||||
): string | null {
|
||||
const parentById = new Map(rows.map((row) => [row.id, row.parentId]));
|
||||
if (!parentById.has(commentId)) return null;
|
||||
const visited = new Set<string>();
|
||||
let currentId = commentId;
|
||||
while (true) {
|
||||
if (visited.has(currentId)) return commentId;
|
||||
visited.add(currentId);
|
||||
const parentId = parentById.get(currentId);
|
||||
if (!parentId || parentId === currentId || !parentById.has(parentId)) return currentId;
|
||||
currentId = parentId;
|
||||
}
|
||||
}
|
||||
|
||||
export function profileCommentFollowerRecipients(
|
||||
authorId: string,
|
||||
directRecipientId: string | null,
|
||||
followerIds: string[]
|
||||
): string[] {
|
||||
return [...new Set(followerIds)].filter(
|
||||
(userId) => userId !== authorId && userId !== directRecipientId
|
||||
);
|
||||
}
|
||||
|
||||
export function profileCommentNotificationRecipient(
|
||||
authorId: string,
|
||||
parentAuthorId: string | null,
|
||||
profileOwnerId: string
|
||||
): string | null {
|
||||
if (parentAuthorId && parentAuthorId !== authorId) return parentAuthorId;
|
||||
return profileOwnerId !== authorId ? profileOwnerId : null;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { normalizeUserMarkdown } from '$lib/markdown';
|
||||
import {
|
||||
moderateCommunityText,
|
||||
recordCommunityModerationScan
|
||||
} from '$lib/server/community-moderation';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
export async function saveProfileBio(user: { id: string; displayName: string }, value: FormDataEntryValue | null) {
|
||||
const bio = normalizeUserMarkdown(value, 4_000);
|
||||
if (bio === null) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: 'Agent biographies may contain up to 4,000 characters and cannot contain control characters.'
|
||||
};
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
const database = await getDatabase();
|
||||
const moderation = await moderateCommunityText({ contentType: 'profile_bio', text: bio });
|
||||
const candidateRevisionId = randomUUID();
|
||||
await (await database.prepare(`INSERT OR IGNORE INTO moderated_content_revisions
|
||||
(id,content_type,content_id,submitted_by,body_markdown,content_hash,created_at)
|
||||
VALUES(?,'profile_bio',?,?,?,?,?)`))
|
||||
.run(candidateRevisionId, user.id, user.id, bio, moderation.contentHash, now);
|
||||
const storedRevision = await (await database.prepare(`SELECT id
|
||||
FROM moderated_content_revisions
|
||||
WHERE content_type='profile_bio' AND content_id=? AND content_hash=?`))
|
||||
.get(user.id, moderation.contentHash);
|
||||
const revisionId = String(storedRevision?.id ?? candidateRevisionId);
|
||||
if (moderation.decision === 'approved') {
|
||||
await (await database.prepare(`INSERT INTO user_profiles
|
||||
(user_id,display_name,bio_markdown,is_public,bio_updated_at,updated_at)
|
||||
VALUES(?,?,?,1,?,?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
bio_markdown=excluded.bio_markdown,
|
||||
bio_updated_at=excluded.bio_updated_at,
|
||||
updated_at=excluded.updated_at`))
|
||||
.run(user.id, user.displayName, bio, now, now);
|
||||
}
|
||||
await recordCommunityModerationScan({
|
||||
contentType: 'profile_bio',
|
||||
contentId: user.id,
|
||||
revisionId,
|
||||
authorId: user.id,
|
||||
result: moderation
|
||||
});
|
||||
return {
|
||||
ok: true as const,
|
||||
bio,
|
||||
reviewQueued: moderation.decision !== 'approved'
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateProfileVisibility(
|
||||
user: { id: string; displayName: string },
|
||||
value: FormDataEntryValue | null
|
||||
) {
|
||||
const isPublicValue = String(value ?? '');
|
||||
if (!['0', '1'].includes(isPublicValue)) {
|
||||
return { ok: false as const, error: 'Choose whether your Agent profile is public.' };
|
||||
}
|
||||
const isPublic = isPublicValue === '1';
|
||||
const now = new Date().toISOString();
|
||||
const database = await getDatabase();
|
||||
await (await database.prepare(`INSERT INTO user_profiles
|
||||
(user_id,display_name,is_public,updated_at)
|
||||
VALUES(?,?,?,?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
is_public=excluded.is_public,
|
||||
updated_at=excluded.updated_at`))
|
||||
.run(user.id, user.displayName, isPublic ? 1 : 0, now);
|
||||
return { ok: true as const, isPublic };
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Database } from '@tursodatabase/database';
|
||||
import { attachWeaponCategoryBonuses } from '$lib/weapons/category-bonuses';
|
||||
|
||||
export async function loadSharedBuildCombatCatalog(database: Database) {
|
||||
const [
|
||||
@@ -49,7 +50,7 @@ export async function loadSharedBuildCombatCatalog(database: Database) {
|
||||
(await database.prepare('SELECT * FROM brand_sets WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT * FROM brand_set_effects ORDER BY pieces_required')).all(),
|
||||
(await database.prepare('SELECT * FROM weapons WHERE active=1 ORDER BY category,name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT * FROM weapon_base_attribute_bonuses ORDER BY sort_order')).all(),
|
||||
(await database.prepare('SELECT * FROM weapon_category_attribute_bonuses ORDER BY weapon_category,sort_order')).all(),
|
||||
(await database.prepare('SELECT * FROM talents WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT * FROM talent_tiers ORDER BY tier')).all(),
|
||||
(await database.prepare('SELECT * FROM talent_tier_effects')).all(),
|
||||
@@ -121,10 +122,7 @@ export async function loadSharedBuildCombatCatalog(database: Database) {
|
||||
...brand,
|
||||
effects: brandEffects.filter((effect) => effect.brand_set_id === brand.id)
|
||||
})),
|
||||
weapons: weapons.map((weapon) => ({
|
||||
...weapon,
|
||||
baseBonuses: weaponBonuses.filter((bonus) => bonus.weapon_id === weapon.id)
|
||||
})),
|
||||
weapons: attachWeaponCategoryBonuses(weapons, weaponBonuses),
|
||||
talents: configuredTalents,
|
||||
chipSets: chipSets.map((set) => {
|
||||
const talent = chipTalents.find((item) => item.skill_chip_set_id === set.id);
|
||||
|
||||
Reference in New Issue
Block a user