Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions layers/identity/app/composables/useActionFailure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,28 @@
// so callers keep their existing "did it work?" checks.
export function useActionFailure() {
const toast = useToast()
return (action: string) => (error: unknown): null => {
const failureIds = useState<Record<string, string | number>>('action-failure-toasts', () => ({}))
const clear = (action: string, scope = action) => {
const id = failureIds.value[scope]
if (id === undefined)
return
const { [scope]: _removed, ...remaining } = failureIds.value
failureIds.value = remaining
toast.remove(id)
}
const failure = (action: string, scope = action) => (error: unknown): null => {
const reason = error instanceof Error ? error.message : String(error)
console.warn(`[action:${action}] ${reason}`)
toast.add({
clear(action, scope)
// Nuxt UI defers removal, so each failure needs a fresh generated ID.
const added = toast.add({
title: `Could not ${action}`,
description: 'Something went wrong. Your change was not saved, so try again.',
color: 'error',
icon: 'i-lucide-alert-circle',
})
failureIds.value = { ...failureIds.value, [scope]: added.id }
return null
}
return Object.assign(failure, { clear })
}
9 changes: 6 additions & 3 deletions layers/identity/app/composables/useLikes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ export function useLikes() {
await ensureLoaded()
const key = likeKey(ref)
const wasLiked = !!liked.value[key]
fail.clear('save your like', `like:${key}`)
const previousCount = likeCounts.value[key]

liked.value = applyLiked(liked.value, key, !wasLiked)
Expand All @@ -121,8 +122,8 @@ export function useLikes() {

const path = `/api/me/likes/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.repo)}/${encodeURIComponent(ref.name)}`
const res = wasLiked
? await $fetch<LikeMutationResponse>(path, { method: 'DELETE' }).catch(fail('remove your like'))
: await $fetch<LikeMutationResponse>('/api/me/likes', { method: 'POST', body: ref }).catch(fail('save your like'))
? await $fetch<LikeMutationResponse>(path, { method: 'DELETE' }).catch(fail('remove your like', `like:${key}`))
: await $fetch<LikeMutationResponse>('/api/me/likes', { method: 'POST', body: ref }).catch(fail('save your like', `like:${key}`))

const { [key]: _dropped, ...restPending } = pending.value
pending.value = restPending
Expand Down Expand Up @@ -151,6 +152,8 @@ export function useLikes() {
if (existing)
return existing

fail.clear('save your like', `like:${key}`)

liked.value = applyLiked(liked.value, key, true)
const previousCount = likeCounts.value[key]
if (previousCount !== undefined)
Expand All @@ -162,7 +165,7 @@ export function useLikes() {
likeCounts.value = { ...likeCounts.value, [key]: response.likeCount }
return true
})
.catch(fail('save your like'))
.catch(fail('save your like', `like:${key}`))
.then((result) => {
if (!result) {
liked.value = applyLiked(liked.value, key, false)
Expand Down
5 changes: 5 additions & 0 deletions layers/identity/app/pages/me/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const removeSubscriptionMutation = useNuxtMutation<IdentitySubscriptionRef, Iden
return result
},
onError: actionFailed('stop watching that repo'),
onMutate: () => actionFailed.clear('stop watching that repo'),
})

const saveEmailMutation = useNuxtMutation<IdentityEmailPatchBody, IdentityMutationResponse>({
Expand All @@ -62,6 +63,7 @@ const saveEmailMutation = useNuxtMutation<IdentityEmailPatchBody, IdentityMutati
return result
},
onError: actionFailed('save your email settings'),
onMutate: () => actionFailed.clear('save your email settings'),
})

const savePrivacyMutation = useNuxtMutation<IdentityPrivacyPatchBody, IdentityMutationResponse>({
Expand All @@ -71,6 +73,7 @@ const savePrivacyMutation = useNuxtMutation<IdentityPrivacyPatchBody, IdentityMu
return result
},
onError: actionFailed('save your privacy settings'),
onMutate: () => actionFailed.clear('save your privacy settings'),
})
async function setLikesPublic(likesPublic: boolean) {
await savePrivacyMutation.mutateSafe({ likes_public: likesPublic })
Expand All @@ -87,6 +90,7 @@ const removeLikeMutation = useNuxtMutation<LikedSkill, { ok: true }>({
return result
},
onError: actionFailed('remove that skill'),
onMutate: () => actionFailed.clear('remove that skill'),
})
const removingSkill = ref<string>()
async function unlike(skill: LikedSkill) {
Expand All @@ -104,6 +108,7 @@ const syncMutation = useNuxtMutation<void, StarsSyncResponse>({
return result
},
onError: actionFailed('sync your starred repos'),
onMutate: () => actionFailed.clear('sync your starred repos'),
})
async function sync() {
await syncMutation.mutateSafe()
Expand Down
4 changes: 3 additions & 1 deletion layers/identity/app/pages/onboarding/discover.vue
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ const submitting = ref(false)
async function watchSelected() {
if (submitting.value || syncing.value)
return
actionFailed.clear('start watching those repos')
if (!selected.value.size) {
await navigateTo('/onboarding/email')
return
Expand Down Expand Up @@ -137,7 +138,8 @@ useSeoMeta({ title: 'Discover skills', robots: 'noindex' })
Page {{ syncProgress.page || '…' }} Β· {{ syncProgress.total }} repos cached Β· {{ syncProgress.matched }} match the registry
</span>
<span v-else-if="data?.syncedAt" class="text-xs text-muted">
Last GitHub import {{ new Date(data.syncedAt * 1000).toLocaleString() }}
Last GitHub import
<NuxtTime :datetime="data.syncedAt * 1000" locale="en-AU" time-zone="UTC" date-style="medium" time-style="short" /> UTC
</span>
</div>

Expand Down
2 changes: 2 additions & 0 deletions layers/identity/app/pages/onboarding/email.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,14 @@ const saveEmailMutation = useNuxtMutation<IdentityEmailPatchBody, IdentityMutati
mutation: body => rpc.execute(identityAccountQueries.saveEmail(), body),
invalidates: ['identity:me'],
onError: actionFailed('save your digest email'),
onMutate: () => actionFailed.clear('save your digest email'),
})

const finishOnboardingMutation = useNuxtMutation<void, IdentityMutationResponse>({
mutation: () => rpc.execute(identityAccountQueries.finishOnboarding()),
invalidates: ['identity:me'],
onError: actionFailed('finish setting up your account'),
onMutate: () => actionFailed.clear('finish setting up your account'),
})

// Opting in needs somewhere to send the digest, so the address is required
Expand Down
10 changes: 3 additions & 7 deletions layers/identity/server/api/me/subscriptions/index.post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { defineApiHandler } from '#shared/server/handler'
import { authenticated } from '../../../policies/authenticated'
import { SubscriptionsCreateInput } from '../../../schemas/subscriptions'
import { requireUserRow } from '../../../utils/users'
import { createRepositoryWatches } from '../../../utils/watches'

export default defineApiHandler({
schema: SubscriptionsCreateInput,
Expand All @@ -10,12 +11,7 @@ export default defineApiHandler({
const u = await requireUserRow(event)
if (!body.repos.length)
return { ok: true as const, inserted: 0 }
const now = Math.floor(Date.now() / 1000)
const stmts = body.repos.map(r => platform.db.prepare(
`INSERT OR IGNORE INTO skill_subscriptions (user_id, owner, repo, source, created_at)
VALUES (?1, ?2, ?3, ?4, ?5)`,
).bind(u.id, r.owner, r.repo, body.source, now))
await platform.db.batch(stmts)
return { ok: true as const, inserted: stmts.length }
const inserted = await createRepositoryWatches(platform.db, u.id, body.repos, body.source)
return { ok: true as const, inserted }
},
})
10 changes: 9 additions & 1 deletion layers/identity/server/utils/digest-select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,15 @@ export async function selectDigestForUser(
FROM activity a
JOIN skills s ON s.owner = a.owner AND s.repo = a.repo AND s.name = a.name
JOIN repos r ON r.owner = s.owner AND r.repo = s.repo
JOIN skill_subscriptions sub ON sub.user_id = ?1 AND sub.owner = s.owner AND sub.repo = s.repo
JOIN (
SELECT owner COLLATE NOCASE AS owner, repo COLLATE NOCASE AS repo, source, muted_until
FROM (
SELECT *, ROW_NUMBER() OVER (
PARTITION BY owner COLLATE NOCASE, repo COLLATE NOCASE
ORDER BY source = 'like', created_at, source, owner, repo
) AS watch_rank FROM skill_subscriptions WHERE user_id = ?1
) WHERE watch_rank = 1
) sub ON sub.owner = s.owner AND sub.repo = s.repo
WHERE a.id > ?2 AND a.id <= ?3
AND (sub.muted_until IS NULL OR sub.muted_until <= ?4)
AND r.repo_kind != 'aggregator'
Expand Down
18 changes: 11 additions & 7 deletions layers/identity/server/utils/starred-repos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,16 @@ export interface StarredRow {

export async function loadStarredRows(db: D1Database, userId: number): Promise<StarredRow[]> {
const res = await db.prepare(
`SELECT s.owner, s.repo, s.starred_at, s.has_skill,
CASE WHEN sub.user_id IS NULL THEN 0 ELSE 1 END as watching,
`SELECT COALESCE(r.owner, s.owner) AS owner, COALESCE(r.repo, s.repo) AS repo, s.starred_at,
CASE WHEN sk.name IS NULL THEN 0 ELSE 1 END AS has_skill,
EXISTS (SELECT 1 FROM skill_subscriptions sub WHERE sub.user_id = s.user_id
AND sub.owner = s.owner COLLATE NOCASE AND sub.repo = s.repo COLLATE NOCASE) AS watching,
sk.name as skill_name, sk.display_name as skill_display, sk.slug as skill_slug
FROM user_starred_repos s
LEFT JOIN skills sk ON sk.owner = s.owner AND sk.repo = s.repo
LEFT JOIN skill_subscriptions sub ON sub.user_id = s.user_id AND sub.owner = s.owner AND sub.repo = s.repo
LEFT JOIN repos r ON r.owner = s.owner COLLATE NOCASE AND r.repo = s.repo COLLATE NOCASE
LEFT JOIN skills sk ON sk.owner = r.owner AND sk.repo = r.repo
WHERE s.user_id = ?1
ORDER BY s.has_skill DESC, s.starred_at DESC, sk.name
ORDER BY has_skill DESC, s.starred_at DESC, sk.name
LIMIT 1000`,
).bind(userId).all<StarredRow>()
return res.results ?? []
Expand Down Expand Up @@ -99,9 +101,11 @@ export async function importStarredPage(input: {
const starredAt = Math.floor(new Date(it.starred_at).getTime() / 1000)
return db.prepare(
`INSERT OR REPLACE INTO user_starred_repos (user_id, owner, repo, starred_at, has_skill)
VALUES (?1, ?2, ?3, ?4,
VALUES (?1,
COALESCE((SELECT owner FROM repos WHERE owner = ?2 COLLATE NOCASE AND repo = ?3 COLLATE NOCASE), ?2),
COALESCE((SELECT repo FROM repos WHERE owner = ?2 COLLATE NOCASE AND repo = ?3 COLLATE NOCASE), ?3), ?4,
(SELECT CASE WHEN COUNT(*) > 0 THEN 1 ELSE 0 END
FROM skills WHERE owner = ?2 AND repo = ?3))`,
FROM skills WHERE owner = ?2 COLLATE NOCASE AND repo = ?3 COLLATE NOCASE))`,
).bind(userId, owner, repo, starredAt)
})
const chunk = 50
Expand Down
14 changes: 3 additions & 11 deletions layers/identity/server/utils/watch-actions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { H3Event } from 'h3'
import { likeSkill } from './likes'
import { createRepositoryWatches } from './watches'

// Post-OAuth side effects driven by ?action= query string. Anonymous "Like X"
// and "Watch X" clicks bounce through OAuth with an action token; the callback
Expand Down Expand Up @@ -44,7 +45,6 @@ export async function handleWatchAction(
returnTo: string,
): Promise<void> {
const ctx = parseReturnTo(returnTo)
const now = Math.floor(Date.now() / 1000)
const d = db(event)

// likeSkill derives the repo subscription itself, so there is no separate
Expand All @@ -55,10 +55,7 @@ export async function handleWatchAction(
}

if (action === 'watch-skill' && ctx.skillOwner && ctx.skillRepo) {
await d.prepare(
`INSERT OR IGNORE INTO skill_subscriptions (user_id, owner, repo, source, created_at)
VALUES (?1, ?2, ?3, 'manual', ?4)`,
).bind(userId, ctx.skillOwner, ctx.skillRepo, now).run()
await createRepositoryWatches(d, userId, [{ owner: ctx.skillOwner, repo: ctx.skillRepo }], 'manual')
return
}

Expand All @@ -71,11 +68,6 @@ export async function handleWatchAction(
WHERE u.login = ?1 AND c.slug = ?2 AND c.deleted_at IS NULL`,
).bind(ctx.collectionLogin, ctx.collectionSlug).all<{ owner: string, repo: string }>()
const source = `collection:${ctx.collectionSlug}`
const stmts = (skills.results ?? []).map(r => d.prepare(
`INSERT OR IGNORE INTO skill_subscriptions (user_id, owner, repo, source, created_at)
VALUES (?1, ?2, ?3, ?4, ?5)`,
).bind(userId, r.owner, r.repo, source, now))
if (stmts.length)
await d.batch(stmts)
await createRepositoryWatches(d, userId, skills.results ?? [], source)
}
}
67 changes: 56 additions & 11 deletions layers/identity/server/utils/watches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,16 @@ export interface WatchRow {
/** Every watch of one person, newest first. */
export async function loadWatches(db: D1Database, userId: number): Promise<WatchRow[]> {
const res = await db.prepare(
`SELECT owner, repo, source, muted_until, created_at
FROM skill_subscriptions
WHERE user_id = ?1
`SELECT COALESCE(r.owner, s.owner) AS owner, COALESCE(r.repo, s.repo) AS repo,
s.source, s.muted_until, s.created_at
FROM (
SELECT *, ROW_NUMBER() OVER (
PARTITION BY owner COLLATE NOCASE, repo COLLATE NOCASE
ORDER BY source = 'like', created_at, source, owner, repo
) AS watch_rank FROM skill_subscriptions WHERE user_id = ?1
) s
LEFT JOIN repos r ON r.owner = s.owner COLLATE NOCASE AND r.repo = s.repo COLLATE NOCASE
WHERE s.watch_rank = 1
ORDER BY created_at DESC`,
).bind(userId).all<WatchRow>()
return res.results ?? []
Expand All @@ -33,7 +40,7 @@ export async function loadWatches(db: D1Database, userId: number): Promise<Watch
/** True when the registry holds the Repository, so the digest can report on it. */
export async function repositoryExists(db: D1Database, ref: RepositoryRef): Promise<boolean> {
const row = await db.prepare(
`SELECT 1 AS found FROM repos WHERE owner = ?1 AND repo = ?2`,
`SELECT 1 AS found FROM repos WHERE owner = ?1 COLLATE NOCASE AND repo = ?2 COLLATE NOCASE`,
).bind(ref.owner, ref.repo).first<{ found: number }>()
return row !== null
}
Expand All @@ -46,17 +53,55 @@ export async function repositoryExists(db: D1Database, ref: RepositoryRef): Prom
* deliberate choice and keeps its reason.
*/
export async function watchRepository(db: D1Database, userId: number, ref: RepositoryRef): Promise<void> {
await db.prepare(
`INSERT INTO skill_subscriptions (user_id, owner, repo, source, created_at)
VALUES (?1, ?2, ?3, 'manual', ?4)
ON CONFLICT (user_id, owner, repo) DO UPDATE SET source = 'manual'
WHERE skill_subscriptions.source = 'like'`,
).bind(userId, ref.owner, ref.repo, Math.floor(Date.now() / 1000)).run()
await createRepositoryWatches(db, userId, [ref], 'manual')
}

/** Resolve GitHub spelling once, then persist the registry's frozen identity. */
export async function createRepositoryWatches(
db: D1Database,
userId: number,
refs: readonly RepositoryRef[],
source: string,
): Promise<number> {
if (!refs.length)
return 0
const matches = await db.batch<RepositoryRef>(refs.map(ref => db.prepare(
`SELECT owner, repo FROM repos WHERE owner = ?1 COLLATE NOCASE AND repo = ?2 COLLATE NOCASE`,
).bind(ref.owner, ref.repo)))
const canonicalRefs = matches.map((match) => {
const canonical = match.results?.[0]
if (!canonical)
throw createError({ statusCode: 404, message: 'Repository not found' })
return canonical
})
const now = Math.floor(Date.now() / 1000)
const result = await db.batch(canonicalRefs.flatMap(canonical => [
db.prepare(
`INSERT INTO skill_subscriptions (user_id, owner, repo, source, muted_until, created_at)
SELECT ?1, ?2, ?3,
COALESCE((SELECT source FROM skill_subscriptions WHERE user_id = ?1
AND owner = ?2 COLLATE NOCASE AND repo = ?3 COLLATE NOCASE AND source != 'like'
ORDER BY created_at, source LIMIT 1), ?4),
(SELECT muted_until FROM skill_subscriptions WHERE user_id = ?1
AND owner = ?2 COLLATE NOCASE AND repo = ?3 COLLATE NOCASE
ORDER BY source = 'like', created_at, source, owner, repo LIMIT 1),
COALESCE((SELECT MIN(created_at) FROM skill_subscriptions WHERE user_id = ?1
AND owner = ?2 COLLATE NOCASE AND repo = ?3 COLLATE NOCASE), ?5)
ON CONFLICT(user_id, owner, repo) DO UPDATE SET
source = excluded.source, muted_until = excluded.muted_until, created_at = excluded.created_at`,
).bind(userId, canonical.owner, canonical.repo, source, now),
db.prepare(
`DELETE FROM skill_subscriptions WHERE user_id = ?1
AND owner = ?2 COLLATE NOCASE AND repo = ?3 COLLATE NOCASE
AND (owner != ?2 OR repo != ?3)`,
).bind(userId, canonical.owner, canonical.repo),
]))
return result.reduce((inserted, row, index) => inserted + (index % 2 === 0 ? row.meta?.changes ?? 0 : 0), 0)
}

/** Remove a watch, whatever its source. Likes stay. */
export async function unwatchRepository(db: D1Database, userId: number, ref: RepositoryRef): Promise<void> {
await db.prepare(
`DELETE FROM skill_subscriptions WHERE user_id = ?1 AND owner = ?2 AND repo = ?3`,
`DELETE FROM skill_subscriptions WHERE user_id = ?1 AND owner = ?2 COLLATE NOCASE AND repo = ?3 COLLATE NOCASE`,
).bind(userId, ref.owner, ref.repo).run()
}
2 changes: 2 additions & 0 deletions migrations/0137_repository_identity_nocase.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- Resolve GitHub spelling without changing frozen registry identities.
CREATE INDEX idx_repos_identity_nocase ON repos(owner COLLATE NOCASE, repo COLLATE NOCASE);
Loading