Skip to content

Commit f807e30

Browse files
authored
Merge pull request #38 from applirank/JoachimLK/issue37
feat: implement preview read-only mode with upsell guidance and error handling
2 parents 253a055 + 0a9a797 commit f807e30

27 files changed

Lines changed: 524 additions & 387 deletions

.env.example

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,13 @@ S3_FORCE_PATH_STYLE=true
6262
# Production: https://applirank.com
6363
NUXT_PUBLIC_SITE_URL=http://localhost:3000
6464

65+
# Public URL used by preview upsell modal "Upgrade to hosted plan" button
66+
# NUXT_PUBLIC_HOSTED_PLAN_URL=https://applirank.com
67+
6568
# ─── Optional: Demo Mode ────────────────────
6669
# Set to an org slug to make that org read-only (blocks mutations)
67-
# DEMO_ORG_SLUG=demo
70+
# Default seeded slug: applirank-demo
71+
# DEMO_ORG_SLUG=applirank-demo
6872

6973
# ─── Optional: Trusted Proxy ────────────────
7074
# IP address of the reverse proxy (Railway/Cloudflare). When set,

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,36 @@ Format follows [Keep a Changelog](https://keepachangelog.com). Categories: **Add
66

77
---
88

9+
## 2026-02-22
10+
11+
### Fixed
12+
13+
- **Railway PR seed execution** — removed hard `.env` dependency from `db:seed`; seeding now works with platform-injected env vars and still supports local `.env` loading in `seed.ts`
14+
15+
### Changed
16+
17+
- **Unified Railway seeding path** — Railway predeploy now runs `db:seed` (same script as standard demo data), removing PR-specific seed divergence between preview and production-like environments
18+
- **Preview demo defaults aligned** — runtime preview fallbacks now target `applirank-demo` and `demo@applirank.com` to match `server/scripts/seed.ts`
19+
20+
### Removed
21+
22+
- **PR-only seed script** — removed `server/scripts/seed-pr.ts` and the `db:seed:pr` npm script
23+
24+
---
25+
926
## 2026-02-21
1027

1128
### Fixed
1229

1330
- **Dependency security remediation** — resolved all `npm audit --audit-level=high` findings by upgrading `@aws-sdk/client-s3` (pulling patched `@aws-sdk/xml-builder`) and regenerating lockfile resolution
1431
- **Transitive vulnerability pinning** — added npm `overrides` for `fast-xml-parser`, `minimatch`, `tar`, and `readdir-glob` to keep vulnerable transitive ranges out of the install graph
32+
- **Demo write-protection enforcement** — hardened server demo guard so `POST`/`PATCH`/`PUT`/`DELETE` requests are consistently blocked for the configured demo organization and no longer silently fail open when demo org lookup fails
33+
- **Dashboard preview UX** — write attempts in preview mode now trigger a dedicated upsell modal instead of only inline/API errors, while keeping action buttons visible
1534

1635
### Changed
1736

1837
- **Lockfile hygiene** — refreshed dependency graph with `npm install` + `npm dedupe` to remove stale vulnerable transitive entries
38+
- **Demo env guidance**`.env.example` demo slug example now matches seeded demo organization slug (`applirank-demo`) to reduce configuration drift
1939

2040
---
2141

app/components/ApplyCandidateModal.vue

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<script setup lang="ts">
22
import { Search, X, UserPlus } from 'lucide-vue-next'
3+
import { usePreviewReadOnly } from '~/composables/usePreviewReadOnly'
34
45
const props = defineProps<{
56
jobId: string
@@ -32,6 +33,7 @@ const { data: candidateData, status: searchStatus } = useFetch('/api/candidates'
3233
})
3334
3435
const candidates = computed(() => candidateData.value?.data ?? [])
36+
const { handlePreviewReadOnlyError } = usePreviewReadOnly()
3537
3638
// Apply candidate
3739
const isApplying = ref(false)
@@ -47,6 +49,7 @@ async function applyCandidate(candidateId: string) {
4749
})
4850
emit('created')
4951
} catch (err: any) {
52+
if (handlePreviewReadOnlyError(err)) return
5053
applyError.value = err.data?.statusMessage ?? 'Failed to apply candidate'
5154
} finally {
5255
isApplying.value = false

app/components/ApplyToJobModal.vue

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<script setup lang="ts">
22
import { X, Briefcase } from 'lucide-vue-next'
3+
import { usePreviewReadOnly } from '~/composables/usePreviewReadOnly'
34
45
const props = defineProps<{
56
candidateId: string
@@ -18,6 +19,7 @@ const { data: jobData, status: jobFetchStatus } = useFetch('/api/jobs', {
1819
})
1920
2021
const jobs = computed(() => jobData.value?.data ?? [])
22+
const { handlePreviewReadOnlyError } = usePreviewReadOnly()
2123
2224
// Apply to job
2325
const isApplying = ref(false)
@@ -33,6 +35,7 @@ async function applyToJob(jobId: string) {
3335
})
3436
emit('created')
3537
} catch (err: any) {
38+
if (handlePreviewReadOnlyError(err)) return
3639
applyError.value = err.data?.statusMessage ?? 'Failed to apply to job'
3740
} finally {
3841
isApplying.value = false

app/components/CandidateDetailSidebar.vue

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
ExternalLink, Mail, Phone, Upload, Download, Eye, Trash2,
55
ArrowLeft, AlertTriangle,
66
} from 'lucide-vue-next'
7+
import { usePreviewReadOnly } from '~/composables/usePreviewReadOnly'
78
89
const props = defineProps<{
910
applicationId: string
@@ -15,6 +16,8 @@ const emit = defineEmits<{
1516
(e: 'updated'): void
1617
}>()
1718
19+
const { handlePreviewReadOnlyError } = usePreviewReadOnly()
20+
1821
// ─────────────────────────────────────────────
1922
// Tabs
2023
// ─────────────────────────────────────────────
@@ -114,6 +117,7 @@ async function handleTransition(newStatus: string) {
114117
await refresh()
115118
emit('updated')
116119
} catch (err: any) {
120+
if (handlePreviewReadOnlyError(err)) return
117121
alert(err.data?.statusMessage ?? 'Failed to update status')
118122
} finally {
119123
isTransitioning.value = false
@@ -144,6 +148,7 @@ async function saveNotes() {
144148
emit('updated')
145149
isEditingNotes.value = false
146150
} catch (err: any) {
151+
if (handlePreviewReadOnlyError(err)) return
147152
alert(err.data?.statusMessage ?? 'Failed to save notes')
148153
} finally {
149154
isSavingNotes.value = false
@@ -247,6 +252,7 @@ async function handleDeleteDoc(docId: string) {
247252
await refreshCandidate()
248253
showDocDeleteConfirm.value = null
249254
} catch (err: any) {
255+
if (handlePreviewReadOnlyError(err)) return
250256
alert(err.data?.statusMessage ?? 'Failed to delete document')
251257
} finally {
252258
isDeletingDoc.value = false
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<script setup lang="ts">
2+
import { Crown, X, Rocket } from 'lucide-vue-next'
3+
4+
const emit = defineEmits<{
5+
(e: 'close'): void
6+
}>()
7+
8+
const { message } = usePreviewReadOnly()
9+
const config = useRuntimeConfig()
10+
11+
function closeModal() {
12+
emit('close')
13+
}
14+
</script>
15+
16+
<template>
17+
<Teleport to="body">
18+
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
19+
<div class="absolute inset-0 bg-black/50" @click="closeModal" />
20+
21+
<div class="relative w-full max-w-md rounded-xl border border-surface-200 bg-white shadow-xl dark:border-surface-800 dark:bg-surface-900">
22+
<div class="flex items-center justify-between border-b border-surface-200 px-5 py-4 dark:border-surface-800">
23+
<div class="flex items-center gap-2">
24+
<Crown class="size-5 text-brand-600 dark:text-brand-400" />
25+
<h3 class="text-lg font-semibold text-surface-900 dark:text-surface-50">Unlock full editing</h3>
26+
</div>
27+
28+
<button
29+
class="cursor-pointer text-surface-400 transition-colors hover:text-surface-600 dark:hover:text-surface-200"
30+
@click="closeModal"
31+
>
32+
<X class="size-5" />
33+
</button>
34+
</div>
35+
36+
<div class="space-y-4 px-5 py-5">
37+
<p class="text-sm text-surface-600 dark:text-surface-300">
38+
{{ message }}
39+
</p>
40+
41+
<p class="text-sm text-surface-500 dark:text-surface-400">
42+
Want write access? Upgrade to a paid hosted plan or deploy your own Applirank instance.
43+
</p>
44+
45+
<div class="flex flex-wrap items-center gap-2">
46+
<a
47+
:href="config.public.hostedPlanUrl"
48+
target="_blank"
49+
rel="noopener noreferrer"
50+
class="inline-flex items-center gap-2 rounded-lg bg-brand-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-brand-700"
51+
>
52+
<Rocket class="size-4" />
53+
Upgrade to hosted plan
54+
</a>
55+
56+
<a
57+
href="https://github.com/applirank/applirank"
58+
target="_blank"
59+
rel="noopener noreferrer"
60+
class="inline-flex items-center rounded-lg border border-surface-300 px-3 py-2 text-sm font-medium text-surface-700 transition-colors hover:bg-surface-50 dark:border-surface-700 dark:text-surface-200 dark:hover:bg-surface-800"
61+
>
62+
Self-host on GitHub
63+
</a>
64+
</div>
65+
</div>
66+
</div>
67+
</div>
68+
</Teleport>
69+
</template>

app/composables/useApplication.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { MaybeRefOrGetter } from 'vue'
55
* Wraps `useFetch('/api/applications/:id')` with a reactive key.
66
*/
77
export function useApplication(id: MaybeRefOrGetter<string>) {
8+
const { handlePreviewReadOnlyError } = usePreviewReadOnly()
89
const applicationId = computed(() => toValue(id))
910

1011
const { data: application, status, error, refresh } = useFetch(
@@ -21,13 +22,18 @@ export function useApplication(id: MaybeRefOrGetter<string>) {
2122
notes: string | null
2223
score: number | null
2324
}>) {
24-
const updated = await $fetch(`/api/applications/${applicationId.value}`, {
25-
method: 'PATCH',
26-
body: payload,
27-
})
28-
await refresh()
29-
await refreshNuxtData('applications')
30-
return updated
25+
try {
26+
const updated = await $fetch(`/api/applications/${applicationId.value}`, {
27+
method: 'PATCH',
28+
body: payload,
29+
})
30+
await refresh()
31+
await refreshNuxtData('applications')
32+
return updated
33+
} catch (error) {
34+
handlePreviewReadOnlyError(error)
35+
throw error
36+
}
3137
}
3238

3339
return { application, status, error, refresh, updateApplication }

app/composables/useApplications.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Ref } from 'vue'
2+
import { usePreviewReadOnly } from '~/composables/usePreviewReadOnly'
23

34
/**
45
* Composable for managing the applications list with filtering, pagination, and mutations.
@@ -9,6 +10,8 @@ export function useApplications(options?: {
910
candidateId?: Ref<string | undefined> | string
1011
status?: Ref<string | undefined> | string
1112
}) {
13+
const { handlePreviewReadOnlyError } = usePreviewReadOnly()
14+
1215
const query = computed(() => ({
1316
...(toValue(options?.jobId) && { jobId: toValue(options?.jobId) }),
1417
...(toValue(options?.candidateId) && { candidateId: toValue(options?.candidateId) }),
@@ -30,12 +33,17 @@ export function useApplications(options?: {
3033
jobId: string
3134
notes?: string
3235
}) {
33-
const created = await $fetch('/api/applications', {
34-
method: 'POST',
35-
body: payload,
36-
})
37-
await refresh()
38-
return created
36+
try {
37+
const created = await $fetch('/api/applications', {
38+
method: 'POST',
39+
body: payload,
40+
})
41+
await refresh()
42+
return created
43+
} catch (error) {
44+
handlePreviewReadOnlyError(error)
45+
throw error
46+
}
3947
}
4048

4149
return {

app/composables/useCandidate.ts

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import type { MaybeRefOrGetter } from 'vue'
2+
import { usePreviewReadOnly } from '~/composables/usePreviewReadOnly'
23

34
/**
45
* Composable for a single candidate detail with update and delete mutations.
56
* Wraps `useFetch('/api/candidates/:id')` with a reactive key.
67
*/
78
export function useCandidate(id: MaybeRefOrGetter<string>) {
9+
const { handlePreviewReadOnlyError } = usePreviewReadOnly()
810
const candidateId = computed(() => toValue(id))
911

1012
const { data: candidate, status, error, refresh } = useFetch(
@@ -22,18 +24,28 @@ export function useCandidate(id: MaybeRefOrGetter<string>) {
2224
email: string
2325
phone: string | null
2426
}>) {
25-
const updated = await $fetch(`/api/candidates/${candidateId.value}`, {
26-
method: 'PATCH',
27-
body: payload,
28-
})
29-
await refresh()
30-
await refreshNuxtData('candidates')
31-
return updated
27+
try {
28+
const updated = await $fetch(`/api/candidates/${candidateId.value}`, {
29+
method: 'PATCH',
30+
body: payload,
31+
})
32+
await refresh()
33+
await refreshNuxtData('candidates')
34+
return updated
35+
} catch (error) {
36+
handlePreviewReadOnlyError(error)
37+
throw error
38+
}
3239
}
3340

3441
/** Delete this candidate and navigate back to the list */
3542
async function deleteCandidate() {
36-
await $fetch(`/api/candidates/${candidateId.value}`, { method: 'DELETE' })
43+
try {
44+
await $fetch(`/api/candidates/${candidateId.value}`, { method: 'DELETE' })
45+
} catch (error) {
46+
handlePreviewReadOnlyError(error)
47+
throw error
48+
}
3749
await refreshNuxtData('candidates')
3850
await navigateTo('/dashboard/candidates')
3951
}

app/composables/useCandidates.ts

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Ref } from 'vue'
2+
import { usePreviewReadOnly } from '~/composables/usePreviewReadOnly'
23

34
/**
45
* Composable for managing the candidates list with search, pagination, and mutations.
@@ -7,6 +8,8 @@ import type { Ref } from 'vue'
78
export function useCandidates(options?: {
89
search?: Ref<string | undefined> | string
910
}) {
11+
const { handlePreviewReadOnlyError } = usePreviewReadOnly()
12+
1013
const query = computed(() => ({
1114
...(toValue(options?.search) && { search: toValue(options?.search) }),
1215
}))
@@ -27,17 +30,27 @@ export function useCandidates(options?: {
2730
email: string
2831
phone?: string
2932
}) {
30-
const created = await $fetch('/api/candidates', {
31-
method: 'POST',
32-
body: payload,
33-
})
34-
await refresh()
35-
return created
33+
try {
34+
const created = await $fetch('/api/candidates', {
35+
method: 'POST',
36+
body: payload,
37+
})
38+
await refresh()
39+
return created
40+
} catch (error) {
41+
handlePreviewReadOnlyError(error)
42+
throw error
43+
}
3644
}
3745

3846
/** Delete a candidate by ID and refresh the list */
3947
async function deleteCandidate(id: string) {
40-
await $fetch(`/api/candidates/${id}`, { method: 'DELETE' })
48+
try {
49+
await $fetch(`/api/candidates/${id}`, { method: 'DELETE' })
50+
} catch (error) {
51+
handlePreviewReadOnlyError(error)
52+
throw error
53+
}
4154
await refresh()
4255
}
4356

0 commit comments

Comments
 (0)