mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-28 17:01:18 +00:00
ENH - refine Weazel News experience
This commit is contained in:
@@ -551,6 +551,15 @@ const defaultLocales: LocaleTree = {
|
||||
category: 'Category',
|
||||
status: 'Publication Status',
|
||||
cover: 'Cover Image',
|
||||
photos: 'Article Photos',
|
||||
photoCount: '{count} of {maximum} photos',
|
||||
addPhotos: 'Add Photos',
|
||||
choosePhotos: 'Choose from Photos',
|
||||
takePhoto: 'Take Photo',
|
||||
photoSourceHint:
|
||||
'Choose several photos from your library or take a new one.',
|
||||
primaryPhoto: 'Cover',
|
||||
makePrimary: 'Use as Cover',
|
||||
chooseCover: 'Choose from Photos',
|
||||
changeCover: 'Change Cover',
|
||||
removeCover: 'Remove Cover',
|
||||
@@ -584,8 +593,10 @@ const defaultLocales: LocaleTree = {
|
||||
deleteArticle: 'Delete {title}',
|
||||
search: 'Search Weazel News',
|
||||
clearSearch: 'Clear article search',
|
||||
articleImages: 'Article photos',
|
||||
coverPreview: 'Article cover preview',
|
||||
removeCover: 'Remove the selected cover image',
|
||||
removePhoto: 'Remove this article photo',
|
||||
status: 'Article status: {status}',
|
||||
},
|
||||
errors: {
|
||||
|
||||
@@ -21,6 +21,16 @@ const article: WeazelNewsArticle = {
|
||||
id: '7d28b252-0532-4869-9512-e313e34d2fb8',
|
||||
imageMediaId: 12,
|
||||
imageUrl: 'https://media.invalid/weazel/article.webp',
|
||||
images: [
|
||||
{
|
||||
mediaId: 12,
|
||||
url: 'https://media.invalid/weazel/article.webp',
|
||||
},
|
||||
{
|
||||
mediaId: 13,
|
||||
url: 'https://media.invalid/weazel/article-2.webp',
|
||||
},
|
||||
],
|
||||
publishedAt: 1_754_000_100,
|
||||
revision: 4,
|
||||
status: 'published',
|
||||
@@ -31,7 +41,7 @@ const article: WeazelNewsArticle = {
|
||||
const draft: WeazelNewsArticleDraft = {
|
||||
body: 'Updated body.',
|
||||
category: 'business',
|
||||
imageMediaId: null,
|
||||
imageMediaIds: [33, 34],
|
||||
status: 'draft',
|
||||
title: 'Updated headline',
|
||||
}
|
||||
@@ -52,6 +62,7 @@ describe('Weazel News store', () => {
|
||||
],
|
||||
jobGradeLabel: 'Editor',
|
||||
jobLabel: 'Weazel News',
|
||||
maximumImages: 6,
|
||||
},
|
||||
success: true,
|
||||
})
|
||||
@@ -61,6 +72,7 @@ describe('Weazel News store', () => {
|
||||
expect(mockNuiCall).toHaveBeenCalledWith('weazel-news:context')
|
||||
expect(store.context?.canManage).toBe(true)
|
||||
expect(store.context?.categories[0]).toEqual({ count: 3, id: 'news' })
|
||||
expect(store.context?.maximumImages).toBe(6)
|
||||
})
|
||||
|
||||
it('loads and deduplicates appended public pages', async () => {
|
||||
@@ -103,6 +115,19 @@ describe('Weazel News store', () => {
|
||||
expect(store.publicHasMore).toBe(false)
|
||||
})
|
||||
|
||||
it('replaces stale search results with a successful empty response', async () => {
|
||||
mockNuiCall.mockResolvedValueOnce({
|
||||
data: { hasMore: false, items: [] },
|
||||
success: true,
|
||||
})
|
||||
const store = useWeazelNewsStore()
|
||||
store.publicItems = [article]
|
||||
|
||||
expect(await store.loadPublic({ search: 'missing headline' })).toBe(true)
|
||||
expect(store.publicItems).toEqual([])
|
||||
expect(store.publicHasMore).toBe(false)
|
||||
})
|
||||
|
||||
it('loads paged management results including drafts', async () => {
|
||||
mockNuiCall.mockResolvedValueOnce({
|
||||
data: { hasMore: false, items: [{ ...article, status: 'draft' }] },
|
||||
|
||||
@@ -20,6 +20,11 @@ export type WeazelNewsCategorySummary = {
|
||||
id: WeazelNewsCategoryId
|
||||
}
|
||||
|
||||
export type WeazelNewsArticleImage = {
|
||||
mediaId: number
|
||||
url: string
|
||||
}
|
||||
|
||||
export type WeazelNewsArticle = {
|
||||
authorName: string
|
||||
body: string
|
||||
@@ -29,6 +34,7 @@ export type WeazelNewsArticle = {
|
||||
id: string
|
||||
imageMediaId?: number | null
|
||||
imageUrl?: string | null
|
||||
images: WeazelNewsArticleImage[]
|
||||
publishedAt?: number | null
|
||||
revision: number
|
||||
status: WeazelNewsArticleStatus
|
||||
@@ -41,7 +47,7 @@ export type WeazelNewsArticleSummary = Omit<WeazelNewsArticle, 'body'>
|
||||
export type WeazelNewsArticleDraft = {
|
||||
body: string
|
||||
category: WeazelNewsCategoryId
|
||||
imageMediaId: number | null
|
||||
imageMediaIds: number[]
|
||||
status: WeazelNewsArticleStatus
|
||||
title: string
|
||||
}
|
||||
@@ -51,6 +57,7 @@ export type WeazelNewsContext = {
|
||||
categories: WeazelNewsCategorySummary[]
|
||||
jobGradeLabel?: string
|
||||
jobLabel?: string
|
||||
maximumImages: number
|
||||
}
|
||||
|
||||
export type WeazelNewsListResponse = {
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(
|
||||
new URL('./weazel-news-app.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
function sectionBetween(start: RegExp, end: RegExp): string {
|
||||
const startMatch = start.exec(source)
|
||||
if (!startMatch || startMatch.index === undefined) return ''
|
||||
|
||||
const tail = source.slice(startMatch.index + startMatch[0].length)
|
||||
const endMatch = end.exec(tail)
|
||||
return endMatch?.index === undefined
|
||||
? source.slice(startMatch.index)
|
||||
: source.slice(
|
||||
startMatch.index,
|
||||
startMatch.index + startMatch[0].length + endMatch.index,
|
||||
)
|
||||
}
|
||||
|
||||
function openingTags(
|
||||
pascalName: string,
|
||||
kebabName: string,
|
||||
value = source,
|
||||
): string[] {
|
||||
const pattern = new RegExp(`<(?:${pascalName}|${kebabName})\\b[^>]*>`, 'gis')
|
||||
return value.match(pattern) ?? []
|
||||
}
|
||||
|
||||
function styleRule(selector: string): string {
|
||||
const start = source.indexOf(selector)
|
||||
if (start < 0) return ''
|
||||
const end = source.indexOf('}', start)
|
||||
return end < 0 ? source.slice(start) : source.slice(start, end + 1)
|
||||
}
|
||||
|
||||
const detailSource = sectionBetween(
|
||||
/<template\s+v-else-if=["']screen\s*===\s*['"]detail['"][^>]*>/i,
|
||||
/<template\s+v-else-if=["']screen\s*===\s*['"]composer['"][^>]*>/i,
|
||||
)
|
||||
const composerSource = sectionBetween(
|
||||
/<template\s+v-else-if=["']screen\s*===\s*['"]composer['"][^>]*>/i,
|
||||
/<(?:SkyActionSheet|sky-action-sheet|SkySheet|sky-sheet)\b/i,
|
||||
)
|
||||
const searchSource = sectionBetween(
|
||||
/<template\s+v-else-if=["']activeTab\s*===\s*['"]search['"][^>]*>/i,
|
||||
/class=["']weazel-editorial-heading["']/i,
|
||||
)
|
||||
|
||||
describe('Weazel News central navigation contract', () => {
|
||||
it('uses the full Sky pill navigation and a tabbar-aware scroll owner', () => {
|
||||
const navigationSource =
|
||||
source.match(
|
||||
/<(?:SkyPillNavigation|sky-pill-navigation)\b[\s\S]*?<\/(?:SkyPillNavigation|sky-pill-navigation)>/i,
|
||||
)?.[0] ?? ''
|
||||
const pillTags = openingTags(
|
||||
'SkyPillNavigation',
|
||||
'sky-pill-navigation',
|
||||
navigationSource,
|
||||
)
|
||||
const segmentedTags = openingTags(
|
||||
'SkySegmented',
|
||||
'sky-segmented',
|
||||
navigationSource,
|
||||
)
|
||||
const segmentedButtonTags = openingTags(
|
||||
'SkySegmentedButton',
|
||||
'sky-segmented-button',
|
||||
navigationSource,
|
||||
)
|
||||
const scrollTags = openingTags('SkyScrollArea', 'sky-scroll-area')
|
||||
|
||||
expect(pillTags).toHaveLength(1)
|
||||
expect(pillTags[0]).toMatch(/\blayout\s*=\s*["']full["']/i)
|
||||
expect(segmentedTags.length).toBeGreaterThanOrEqual(1)
|
||||
expect(segmentedButtonTags).toHaveLength(4)
|
||||
expect(
|
||||
scrollTags.some((tag) => /\bwith-(?:tabbar|tab-bar)\b/i.test(tag)),
|
||||
).toBe(true)
|
||||
|
||||
expect(source).not.toMatch(/<(?:SkyTabBar|sky-tab-bar)\b/i)
|
||||
expect(source).not.toMatch(/\bSkyTabBar\b/)
|
||||
expect(source).not.toMatch(/\.weazel-tabbar(?:\b|__)/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Weazel News article detail contract', () => {
|
||||
it('uses the centered back action owned by SkyNavbar', () => {
|
||||
const navbarTag = detailSource.match(
|
||||
/<(?:SkyNavbar|sky-navbar)\b[^>]*>/is,
|
||||
)?.[0]
|
||||
|
||||
expect(detailSource).not.toBe('')
|
||||
expect(navbarTag).toBeDefined()
|
||||
expect(navbarTag).toMatch(/\bshow-(?:back|back-button)\b/i)
|
||||
expect(navbarTag).toMatch(/\bback-appearance\s*=\s*["']surface["']/i)
|
||||
expect(navbarTag).toMatch(/@back\s*=\s*["']closeDetail["']/)
|
||||
expect(detailSource).not.toMatch(
|
||||
/<(?:SkyNavbarBackLink|sky-navbar-back-link)\b/i,
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the lead image full bleed while aligning article copy to the page gutter', () => {
|
||||
const detailScrollTag = detailSource.match(
|
||||
/<(?:SkyScrollArea|sky-scroll-area)\b(?=[^>]*\bweazel-detail-scroll\b)[^>]*>/is,
|
||||
)?.[0]
|
||||
const coverRule = styleRule('.weazel-detail-cover')
|
||||
const copyRule = styleRule('.weazel-detail-copy')
|
||||
|
||||
expect(detailScrollTag).toBeDefined()
|
||||
expect(detailScrollTag).toMatch(/\bas\s*=\s*["']article["']/i)
|
||||
expect(detailScrollTag).not.toMatch(/\bpadded\b/i)
|
||||
expect(coverRule).toMatch(/\bwidth\s*:\s*100%\s*;/i)
|
||||
expect(coverRule).not.toMatch(/calc\s*\(\s*100%/i)
|
||||
expect(coverRule).not.toMatch(/margin[^;]*-\d/i)
|
||||
expect(copyRule).toContain('var(--sky-page-gutter)')
|
||||
})
|
||||
|
||||
it('offers one canonical edit action to article writers', () => {
|
||||
const editActions =
|
||||
detailSource.match(
|
||||
/@click\s*=\s*["']\s*editArticle\s*\(\s*selectedArticle\s*\)\s*["']/g,
|
||||
) ?? []
|
||||
const navbarEnd = detailSource.search(/<\/(?:SkyNavbar|sky-navbar)>/i)
|
||||
const navbarSource = navbarEnd < 0 ? '' : detailSource.slice(0, navbarEnd)
|
||||
|
||||
expect(editActions).toHaveLength(1)
|
||||
expect(navbarSource).toMatch(
|
||||
/@click\s*=\s*["']\s*editArticle\s*\(\s*selectedArticle\s*\)\s*["']/,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Weazel News empty search contract', () => {
|
||||
it('uses the shared compact empty state when no article matches', () => {
|
||||
const emptyStateTag = searchSource.match(
|
||||
/<(?:SkyEmptyState|sky-empty-state)\b[^>]*>/is,
|
||||
)?.[0]
|
||||
|
||||
expect(searchSource).not.toBe('')
|
||||
expect(emptyStateTag).toBeDefined()
|
||||
expect(emptyStateTag).toMatch(/\bcompact\b/i)
|
||||
expect(emptyStateTag).toMatch(/!\s*news\.publicItems\.length/)
|
||||
expect(emptyStateTag).toContain("t('search.emptyTitle')")
|
||||
expect(emptyStateTag).toContain("t('search.emptyBody')")
|
||||
})
|
||||
})
|
||||
|
||||
describe('Weazel News article composer contract', () => {
|
||||
it('uses the central SkyField select for the article category', () => {
|
||||
const categorySelect = openingTags('SkyField', 'sky-field').find(
|
||||
(tag) =>
|
||||
/\btype\s*=\s*["']select["']/i.test(tag) && /draft\.category/.test(tag),
|
||||
)
|
||||
|
||||
expect(categorySelect).toBeDefined()
|
||||
expect(categorySelect).toMatch(/:options\s*=/i)
|
||||
expect(categorySelect).toMatch(/\bdropdown\b/i)
|
||||
expect(categorySelect).toContain("t('composer.category')")
|
||||
expect(composerSource).not.toContain("composerChoice = 'category'")
|
||||
})
|
||||
|
||||
it('offers Photos and Camera through the shared action sheet', () => {
|
||||
const actionSheet = source.match(
|
||||
/<(?:SkyActionSheet|sky-action-sheet)\b[\s\S]*?<\/(?:SkyActionSheet|sky-action-sheet)>/i,
|
||||
)?.[0]
|
||||
|
||||
expect(actionSheet).toBeDefined()
|
||||
expect(actionSheet).toMatch(
|
||||
/<(?:SkyActionButton|sky-action-button)\b[\s\S]*?(?:photos|gallery)/i,
|
||||
)
|
||||
expect(actionSheet).toMatch(
|
||||
/<(?:SkyActionButton|sky-action-button)\b[\s\S]*?camera/i,
|
||||
)
|
||||
expect(actionSheet).toMatch(/@backdropclick\s*=/i)
|
||||
expect(actionSheet).toMatch(/@escape\s*=/i)
|
||||
})
|
||||
|
||||
it('round-trips and previews multiple article images', () => {
|
||||
expect(source).toContain('imageMediaIds')
|
||||
expect(source).toContain('maximumImages')
|
||||
expect(source).toMatch(/messageMedia\.begin\s*\(/)
|
||||
expect(source).toMatch(/messageMedia\.consumeMany(?:<[^>]+>)?\s*\(/)
|
||||
expect(source).toMatch(/selection\?*\.media/)
|
||||
expect(source).not.toMatch(/selection\?*\.media\s*\[\s*0\s*\]/)
|
||||
expect(composerSource).toMatch(
|
||||
/v-for\s*=\s*["'][^"']+\s+in\s+[^"']*(?:image|media)[^"']*["']/i,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/['"]camera['"]\s*\|\s*['"]photos['"]|['"]photos['"]\s*\|\s*['"]camera['"]/,
|
||||
)
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2800,6 +2800,7 @@ mockMedia.push(
|
||||
})),
|
||||
)
|
||||
const weazelNewsCategoryIds = ['official', 'events', 'jobs', 'news', 'business']
|
||||
const weazelNewsMaxImages = 6
|
||||
let weazelNewsSequence = 8
|
||||
let weazelNewsArticles = [
|
||||
{
|
||||
@@ -2809,8 +2810,7 @@ let weazelNewsArticles = [
|
||||
excerpt:
|
||||
'Temporary navigation restrictions are in effect around the southern harbor while crews inspect the main shipping channel.',
|
||||
category: 'official',
|
||||
imageUrl: 'https://picsum.photos/seed/weazel-harbor/1200/760',
|
||||
imageMediaId: null,
|
||||
images: weazelNewsImages([15, 13, 20]),
|
||||
authorName: 'Avery Brooks',
|
||||
createdAt: Date.now() - 35 * 60 * 1000,
|
||||
updatedAt: Date.now() - 28 * 60 * 1000,
|
||||
@@ -2825,8 +2825,7 @@ let weazelNewsArticles = [
|
||||
excerpt:
|
||||
'Food stands, live performers, and classic cars are coming to Vinewood Boulevard this weekend.',
|
||||
category: 'events',
|
||||
imageUrl: 'https://picsum.photos/seed/weazel-vinewood/1200/760',
|
||||
imageMediaId: null,
|
||||
images: weazelNewsImages([11]),
|
||||
authorName: 'Maya Chen',
|
||||
createdAt: Date.now() - 2 * 60 * 60 * 1000,
|
||||
updatedAt: Date.now() - 2 * 60 * 60 * 1000,
|
||||
@@ -2841,8 +2840,7 @@ let weazelNewsArticles = [
|
||||
excerpt:
|
||||
'City departments are recruiting new staff across emergency response, transport, and public administration.',
|
||||
category: 'jobs',
|
||||
imageUrl: null,
|
||||
imageMediaId: null,
|
||||
images: [],
|
||||
authorName: 'Jordan Hayes',
|
||||
createdAt: Date.now() - 4 * 60 * 60 * 1000,
|
||||
updatedAt: Date.now() - 3 * 60 * 60 * 1000,
|
||||
@@ -2857,8 +2855,7 @@ let weazelNewsArticles = [
|
||||
excerpt:
|
||||
'Every lane through Del Perro has reopened after crews cleared an earlier road obstruction.',
|
||||
category: 'news',
|
||||
imageUrl: 'https://picsum.photos/seed/weazel-del-perro/1200/760',
|
||||
imageMediaId: null,
|
||||
images: weazelNewsImages([9]),
|
||||
authorName: 'Avery Brooks',
|
||||
createdAt: Date.now() - 7 * 60 * 60 * 1000,
|
||||
updatedAt: Date.now() - 6 * 60 * 60 * 1000,
|
||||
@@ -2873,8 +2870,7 @@ let weazelNewsArticles = [
|
||||
excerpt:
|
||||
'Independent downtown retailers are seeing stronger evening trade during a trial of extended opening hours.',
|
||||
category: 'business',
|
||||
imageUrl: 'https://picsum.photos/seed/weazel-downtown/1200/760',
|
||||
imageMediaId: null,
|
||||
images: weazelNewsImages([5]),
|
||||
authorName: 'Maya Chen',
|
||||
createdAt: Date.now() - 26 * 60 * 60 * 1000,
|
||||
updatedAt: Date.now() - 25 * 60 * 60 * 1000,
|
||||
@@ -2889,8 +2885,7 @@ let weazelNewsArticles = [
|
||||
excerpt:
|
||||
'Local racing teams are preparing vehicles and reviewing safety procedures for the next sanctioned season.',
|
||||
category: 'events',
|
||||
imageUrl: 'https://picsum.photos/seed/sky-phone-3/800/600',
|
||||
imageMediaId: 3,
|
||||
images: weazelNewsImages([3, 7]),
|
||||
authorName: 'Jordan Hayes',
|
||||
createdAt: Date.now() - 55 * 60 * 1000,
|
||||
updatedAt: Date.now() - 12 * 60 * 1000,
|
||||
@@ -2905,8 +2900,7 @@ let weazelNewsArticles = [
|
||||
excerpt:
|
||||
'The editorial desk is collecting confirmed service notices and transport updates for Monday morning.',
|
||||
category: 'official',
|
||||
imageUrl: null,
|
||||
imageMediaId: null,
|
||||
images: [],
|
||||
authorName: 'Jordan Hayes',
|
||||
createdAt: Date.now() - 18 * 60 * 1000,
|
||||
updatedAt: Date.now() - 8 * 60 * 1000,
|
||||
@@ -2914,7 +2908,7 @@ let weazelNewsArticles = [
|
||||
status: 'draft',
|
||||
revision: 2,
|
||||
},
|
||||
]
|
||||
].map(syncWeazelNewsArticleImages)
|
||||
|
||||
function weazelNewsExcerpt(body) {
|
||||
const normalized = body.replace(/\s+/g, ' ').trim()
|
||||
@@ -2931,6 +2925,24 @@ function weazelNewsImageUrl(imageMediaId) {
|
||||
return media?.url ?? null
|
||||
}
|
||||
|
||||
function weazelNewsImages(imageMediaIds) {
|
||||
return imageMediaIds.map((mediaId) => ({
|
||||
mediaId,
|
||||
url: weazelNewsImageUrl(mediaId),
|
||||
}))
|
||||
}
|
||||
|
||||
function syncWeazelNewsArticleImages(article) {
|
||||
const images = Array.isArray(article.images) ? article.images : []
|
||||
const firstImage = images[0] ?? null
|
||||
return {
|
||||
...article,
|
||||
imageMediaId: firstImage?.mediaId ?? null,
|
||||
imageUrl: firstImage?.url ?? null,
|
||||
images,
|
||||
}
|
||||
}
|
||||
|
||||
function validateWeazelNewsDraft(data) {
|
||||
const title = typeof data.title === 'string' ? data.title.trim() : ''
|
||||
const body = typeof data.body === 'string' ? data.body.trim() : ''
|
||||
@@ -2948,24 +2960,45 @@ function validateWeazelNewsDraft(data) {
|
||||
return { error: status === 'draft' ? 'invalid_draft' : 'invalid_publish' }
|
||||
}
|
||||
|
||||
let imageMediaId = null
|
||||
if (data.imageMediaId !== null && data.imageMediaId !== undefined) {
|
||||
imageMediaId = Number(data.imageMediaId)
|
||||
const requestedImageMediaIds =
|
||||
data.imageMediaIds === undefined
|
||||
? data.imageMediaId === null || data.imageMediaId === undefined
|
||||
? []
|
||||
: [data.imageMediaId]
|
||||
: data.imageMediaIds
|
||||
if (
|
||||
!Array.isArray(requestedImageMediaIds) ||
|
||||
requestedImageMediaIds.length > weazelNewsMaxImages
|
||||
) {
|
||||
return { error: 'invalid_attachment' }
|
||||
}
|
||||
|
||||
const imageMediaIds = []
|
||||
const seenImageMediaIds = new Set()
|
||||
for (const requestedImageMediaId of requestedImageMediaIds) {
|
||||
const imageMediaId = Number(requestedImageMediaId)
|
||||
if (
|
||||
!Number.isSafeInteger(imageMediaId) ||
|
||||
seenImageMediaIds.has(imageMediaId) ||
|
||||
!weazelNewsImageUrl(imageMediaId)
|
||||
) {
|
||||
return { error: 'invalid_attachment' }
|
||||
}
|
||||
seenImageMediaIds.add(imageMediaId)
|
||||
imageMediaIds.push(imageMediaId)
|
||||
}
|
||||
|
||||
const images = weazelNewsImages(imageMediaIds)
|
||||
const firstImage = images[0] ?? null
|
||||
|
||||
return {
|
||||
article: {
|
||||
body,
|
||||
category: data.category,
|
||||
excerpt: weazelNewsExcerpt(body),
|
||||
imageMediaId,
|
||||
imageUrl: weazelNewsImageUrl(imageMediaId),
|
||||
imageMediaId: firstImage?.mediaId ?? null,
|
||||
imageUrl: firstImage?.url ?? null,
|
||||
images,
|
||||
status,
|
||||
title,
|
||||
},
|
||||
@@ -4822,6 +4855,7 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
...(canManageWeazelNews
|
||||
? { jobGradeLabel: 'Senior Reporter', jobLabel: 'Weazel News' }
|
||||
: {}),
|
||||
maximumImages: weazelNewsMaxImages,
|
||||
},
|
||||
})
|
||||
return
|
||||
@@ -7730,9 +7764,7 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
const property = mockHousingOverview.properties.find(
|
||||
(item) => item.id === request.body.propertyId,
|
||||
)
|
||||
const existingNames = new Set(
|
||||
(property?.keys ?? []).map((key) => key.name),
|
||||
)
|
||||
const existingNames = new Set((property?.keys ?? []).map((key) => key.name))
|
||||
response.json({
|
||||
success: true,
|
||||
data: {
|
||||
@@ -7774,8 +7806,7 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
if (request.body.action === 'revoke_key') {
|
||||
property.keys = (property.keys ?? []).filter(
|
||||
(key) =>
|
||||
key.identifier !== request.body.identifier ||
|
||||
key.revocable === false,
|
||||
key.identifier !== request.body.identifier || key.revocable === false,
|
||||
)
|
||||
}
|
||||
response.json({ success: true, data: { accepted: true } })
|
||||
|
||||
@@ -65,6 +65,8 @@ const browserDataRequests = [
|
||||
['skyride:history', {}],
|
||||
['skyride:get-player-coords', {}],
|
||||
['weather:get', {}],
|
||||
['weazel-news:context', {}],
|
||||
['weazel-news:list', { category: null, offset: 0, search: '' }],
|
||||
]
|
||||
|
||||
async function post(baseUrl, endpoint, body = {}) {
|
||||
@@ -104,7 +106,89 @@ async function verifyStatefulActions(baseUrl) {
|
||||
true,
|
||||
)
|
||||
gallery = await expectSuccess(baseUrl, 'gallery:list', {}, true)
|
||||
assert.equal(gallery.find((item) => item.id === gallery[0].id)?.favorite, true)
|
||||
assert.equal(
|
||||
gallery.find((item) => item.id === gallery[0].id)?.favorite,
|
||||
true,
|
||||
)
|
||||
|
||||
const articlePhotos = gallery
|
||||
.filter((item) => item.mediaType === 'photo')
|
||||
.slice(0, 7)
|
||||
assert.equal(
|
||||
articlePhotos.length,
|
||||
7,
|
||||
'gallery:list did not include enough photos for Weazel News',
|
||||
)
|
||||
const weazelContext = await expectSuccess(
|
||||
baseUrl,
|
||||
'weazel-news:context',
|
||||
{},
|
||||
true,
|
||||
)
|
||||
assert.equal(weazelContext.maximumImages, 6)
|
||||
const createdArticleResponse = await expectSuccess(
|
||||
baseUrl,
|
||||
'weazel-news:create',
|
||||
{
|
||||
body: 'Created by the browser mock smoke test with several photos.',
|
||||
category: 'news',
|
||||
imageMediaIds: articlePhotos.slice(0, 3).map((item) => item.id),
|
||||
status: 'published',
|
||||
title: 'Browser test Weazel article',
|
||||
},
|
||||
true,
|
||||
)
|
||||
const createdArticle = createdArticleResponse.article
|
||||
assert.deepEqual(
|
||||
createdArticle.images.map((image) => image.mediaId),
|
||||
articlePhotos.slice(0, 3).map((item) => item.id),
|
||||
'weazel-news:create did not preserve image order',
|
||||
)
|
||||
assert.equal(createdArticle.imageMediaId, articlePhotos[0].id)
|
||||
|
||||
const updatedArticleResponse = await expectSuccess(
|
||||
baseUrl,
|
||||
'weazel-news:update',
|
||||
{
|
||||
body: createdArticle.body,
|
||||
category: 'business',
|
||||
id: createdArticle.id,
|
||||
imageMediaIds: [articlePhotos[2].id, articlePhotos[0].id],
|
||||
revision: createdArticle.revision,
|
||||
status: 'draft',
|
||||
title: 'Updated browser test Weazel article',
|
||||
},
|
||||
true,
|
||||
)
|
||||
const updatedArticle = updatedArticleResponse.article
|
||||
assert.deepEqual(
|
||||
updatedArticle.images.map((image) => image.mediaId),
|
||||
[articlePhotos[2].id, articlePhotos[0].id],
|
||||
'weazel-news:update did not preserve the reordered images',
|
||||
)
|
||||
assert.equal(updatedArticle.imageMediaId, articlePhotos[2].id)
|
||||
|
||||
const loadedArticleResponse = await expectSuccess(
|
||||
baseUrl,
|
||||
'weazel-news:get',
|
||||
{ id: updatedArticle.id, manage: true },
|
||||
true,
|
||||
)
|
||||
assert.deepEqual(loadedArticleResponse.article.images, updatedArticle.images)
|
||||
|
||||
const tooManyImages = await post(baseUrl, 'weazel-news:create', {
|
||||
body: 'This article must be rejected because it has too many photos.',
|
||||
category: 'news',
|
||||
imageMediaIds: articlePhotos.map((item) => item.id),
|
||||
status: 'draft',
|
||||
title: 'Invalid Weazel article',
|
||||
})
|
||||
assert.equal(tooManyImages.success, false)
|
||||
assert.equal(tooManyImages.error, 'invalid_attachment')
|
||||
await expectSuccess(baseUrl, 'weazel-news:delete', {
|
||||
id: updatedArticle.id,
|
||||
revision: updatedArticle.revision,
|
||||
})
|
||||
|
||||
const memoBootstrap = await expectSuccess(
|
||||
baseUrl,
|
||||
|
||||
@@ -302,6 +302,14 @@ Locales["en"] = {
|
||||
category = "Category",
|
||||
status = "Publication Status",
|
||||
cover = "Cover Image",
|
||||
photos = "Article Photos",
|
||||
photoCount = "{count} of {maximum} photos",
|
||||
addPhotos = "Add Photos",
|
||||
choosePhotos = "Choose from Photos",
|
||||
takePhoto = "Take Photo",
|
||||
photoSourceHint = "Choose several photos from your library or take a new one.",
|
||||
primaryPhoto = "Cover",
|
||||
makePrimary = "Use as Cover",
|
||||
chooseCover = "Choose from Photos",
|
||||
changeCover = "Change Cover",
|
||||
removeCover = "Remove Cover",
|
||||
@@ -335,8 +343,10 @@ Locales["en"] = {
|
||||
deleteArticle = "Delete {title}",
|
||||
search = "Search Weazel News",
|
||||
clearSearch = "Clear article search",
|
||||
articleImages = "Article photos",
|
||||
coverPreview = "Article cover preview",
|
||||
removeCover = "Remove the selected cover image",
|
||||
removePhoto = "Remove this article photo",
|
||||
status = "Article status: {status}",
|
||||
},
|
||||
errors = {
|
||||
|
||||
@@ -2,6 +2,7 @@ Config.WeazelNews = {
|
||||
Enabled = true,
|
||||
PageSize = 20,
|
||||
MaximumOffset = 10000,
|
||||
MaximumImages = 6,
|
||||
SearchMaxLength = 80,
|
||||
DraftTitleMinLength = 1,
|
||||
DraftBodyMinLength = 1,
|
||||
|
||||
@@ -2663,9 +2663,34 @@ local schema = {
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_weazel_article_media",
|
||||
columns = {
|
||||
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
|
||||
{ name = "article_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "media_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "position", type = "TINYINT UNSIGNED NOT NULL" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
uniqueKeys = {
|
||||
{ name = "uniq_sky_phone_weazel_article_position", columns = "(`article_id`, `position`)" },
|
||||
{ name = "uniq_sky_phone_weazel_article_media", columns = "(`article_id`, `media_id`)" },
|
||||
},
|
||||
foreignKeys = {
|
||||
{ column = "article_id", references = "`sky_phone_weazel_articles` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "media_id", references = "`sky_phone_media` (`id`) ON DELETE CASCADE" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
}
|
||||
|
||||
Bridge.Database.Migrate("sky_phone", schema)
|
||||
Bridge.Database.Query([[
|
||||
INSERT IGNORE INTO `sky_phone_weazel_article_media` (`article_id`, `media_id`, `position`)
|
||||
SELECT `id`, `image_media_id`, 1
|
||||
FROM `sky_phone_weazel_articles`
|
||||
WHERE `image_media_id` IS NOT NULL
|
||||
]], {})
|
||||
Bridge.Database.Query("DELETE FROM `sky_phone_feather_notifications` WHERE `kind` = 'repost'", {})
|
||||
Bridge.Database.Query("DELETE FROM `sky_phone_feather_reactions` WHERE `kind` = 'repost'", {})
|
||||
Bridge.Database.Query([[
|
||||
|
||||
@@ -17,6 +17,7 @@ if type(config.Enabled) ~= "boolean" then
|
||||
end
|
||||
require_integer_config("PageSize", 1, 100)
|
||||
require_integer_config("MaximumOffset", 0, 1000000)
|
||||
require_integer_config("MaximumImages", 1, 6)
|
||||
require_integer_config("SearchMaxLength", 1, 256)
|
||||
require_integer_config("DraftTitleMinLength", 1, 160)
|
||||
require_integer_config("DraftBodyMinLength", 1, 12000)
|
||||
@@ -219,6 +220,7 @@ local function article_dto(row)
|
||||
category = row.category,
|
||||
imageUrl = row.image_url,
|
||||
imageMediaId = row.image_media_id and tonumber(row.image_media_id) or nil,
|
||||
images = {},
|
||||
authorName = row.author_name,
|
||||
createdAt = created_at * 1000,
|
||||
updatedAt = updated_at * 1000,
|
||||
@@ -228,6 +230,54 @@ local function article_dto(row)
|
||||
}
|
||||
end
|
||||
|
||||
local function attach_article_images(articles)
|
||||
if #articles < 1 then
|
||||
return articles
|
||||
end
|
||||
|
||||
local placeholders = {}
|
||||
local article_ids = {}
|
||||
local articles_by_id = {}
|
||||
for index, article in ipairs(articles) do
|
||||
placeholders[index] = "?"
|
||||
article_ids[index] = article.id
|
||||
article.images = {}
|
||||
articles_by_id[article.id] = article
|
||||
end
|
||||
|
||||
local rows = Bridge.Database.Query(([[
|
||||
SELECT relation.`article_id`, relation.`media_id`, relation.`position`, media.`url`
|
||||
FROM `sky_phone_weazel_article_media` relation
|
||||
JOIN `sky_phone_media` media
|
||||
ON media.`id` = relation.`media_id` AND media.`media_type` = 'photo'
|
||||
WHERE relation.`article_id` IN (%s)
|
||||
ORDER BY relation.`article_id`, relation.`position`, relation.`id`
|
||||
]]):format(table.concat(placeholders, ", ")), article_ids)
|
||||
for _, row in ipairs(rows) do
|
||||
local article = articles_by_id[row.article_id]
|
||||
local media_id = tonumber(row.media_id)
|
||||
if article and media_id and type(row.url) == "string" then
|
||||
article.images[#article.images + 1] = {
|
||||
mediaId = media_id,
|
||||
url = row.url,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
for _, article in ipairs(articles) do
|
||||
if #article.images < 1 and article.imageMediaId and article.imageUrl then
|
||||
article.images[1] = {
|
||||
mediaId = article.imageMediaId,
|
||||
url = article.imageUrl,
|
||||
}
|
||||
end
|
||||
local cover = article.images[1]
|
||||
article.imageMediaId = cover and cover.mediaId or nil
|
||||
article.imageUrl = cover and cover.url or nil
|
||||
end
|
||||
return articles
|
||||
end
|
||||
|
||||
local function load_article(id, include_drafts)
|
||||
local visibility = include_drafts and "" or " AND article.`status` = 'published'"
|
||||
local rows = Bridge.Database.Query(([[
|
||||
@@ -237,7 +287,8 @@ local function load_article(id, include_drafts)
|
||||
WHERE article.`id` = ? AND article.`deleted_at` IS NULL%s
|
||||
LIMIT 1
|
||||
]]):format(article_detail_columns, visibility), { id })
|
||||
return article_dto(rows[1])
|
||||
local article = article_dto(rows[1])
|
||||
return article and attach_article_images({ article })[1] or nil
|
||||
end
|
||||
|
||||
local function query_articles(where_clause, parameters, order_clause, offset)
|
||||
@@ -264,10 +315,42 @@ local function query_articles(where_clause, parameters, order_clause, offset)
|
||||
for _, row in ipairs(rows) do
|
||||
articles[#articles + 1] = article_dto(row)
|
||||
end
|
||||
return articles, has_more
|
||||
return attach_article_images(articles), has_more
|
||||
end
|
||||
|
||||
local function validate_article(source, data, retained_media_id)
|
||||
local function validate_article_media(source, data, retained_media_ids)
|
||||
local values = data.imageMediaIds
|
||||
if values == nil then
|
||||
values = data.imageMediaId ~= nil and { data.imageMediaId } or {}
|
||||
end
|
||||
if type(values) ~= "table" or #values > config.MaximumImages then
|
||||
return nil
|
||||
end
|
||||
for key in pairs(values) do
|
||||
if type(key) ~= "number" or key < 1 or key > #values or key ~= math.floor(key) then
|
||||
return nil
|
||||
end
|
||||
end
|
||||
|
||||
local media_ids = {}
|
||||
local seen = {}
|
||||
for index, value in ipairs(values) do
|
||||
local media_id = valid_integer(value, 1, 9007199254740991)
|
||||
if not media_id or seen[media_id] then
|
||||
return nil
|
||||
end
|
||||
if not retained_media_ids[media_id]
|
||||
and not SkyPhoneMedia.ResolveOwnedMedia(source, tostring(media_id), "photo")
|
||||
then
|
||||
return nil
|
||||
end
|
||||
seen[media_id] = true
|
||||
media_ids[index] = media_id
|
||||
end
|
||||
return media_ids
|
||||
end
|
||||
|
||||
local function validate_article(source, data, retained_media_ids)
|
||||
if type(data) ~= "table" then
|
||||
return nil, "invalid_article"
|
||||
end
|
||||
@@ -287,29 +370,40 @@ local function validate_article(source, data, retained_media_id)
|
||||
return nil, status == "draft" and "invalid_draft" or "invalid_publish"
|
||||
end
|
||||
|
||||
local media_id
|
||||
if data.imageMediaId ~= nil then
|
||||
media_id = valid_integer(data.imageMediaId, 1, 9007199254740991)
|
||||
if not media_id then
|
||||
return nil, "invalid_attachment"
|
||||
end
|
||||
if media_id ~= retained_media_id then
|
||||
local url = SkyPhoneMedia.ResolveOwnedMedia(source, tostring(media_id), "photo")
|
||||
if not url then
|
||||
return nil, "invalid_attachment"
|
||||
end
|
||||
end
|
||||
local media_ids = validate_article_media(source, data, retained_media_ids or {})
|
||||
if not media_ids then
|
||||
return nil, "invalid_attachment"
|
||||
end
|
||||
return {
|
||||
title = title,
|
||||
body = body,
|
||||
excerpt = make_excerpt(body),
|
||||
category = data.category,
|
||||
image_media_id = media_id,
|
||||
image_media_id = media_ids[1],
|
||||
image_media_ids = media_ids,
|
||||
status = status,
|
||||
}
|
||||
end
|
||||
|
||||
local function article_matches(article, expected, revision)
|
||||
if not article or article.revision ~= revision
|
||||
or article.title ~= expected.title
|
||||
or article.body ~= expected.body
|
||||
or article.excerpt ~= expected.excerpt
|
||||
or article.category ~= expected.category
|
||||
or article.status ~= expected.status
|
||||
or #article.images ~= #expected.image_media_ids
|
||||
then
|
||||
return false
|
||||
end
|
||||
for index, media_id in ipairs(expected.image_media_ids) do
|
||||
if article.images[index].mediaId ~= media_id then
|
||||
return false
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:weazel-news:context", function(source)
|
||||
local _, error_response = require_phone(source, "read", config.RateLimits.Read)
|
||||
if error_response then
|
||||
@@ -340,6 +434,7 @@ Bridge.Callbacks.Register("sky_phone:weazel-news:context", function(source)
|
||||
jobLabel = access and access.job_label or nil,
|
||||
jobGradeLabel = access and access.grade_label or nil,
|
||||
categories = category_context,
|
||||
maximumImages = config.MaximumImages,
|
||||
},
|
||||
}
|
||||
end)
|
||||
@@ -466,24 +561,39 @@ Bridge.Callbacks.Register("sky_phone:weazel-news:create", function(source, data)
|
||||
if not valid_uuid(id) then
|
||||
error("[sky_phone] Database did not generate a Weazel News article id.")
|
||||
end
|
||||
Bridge.Database.Query([[
|
||||
INSERT INTO `sky_phone_weazel_articles`
|
||||
(`id`, `title`, `body`, `excerpt`, `category`, `image_media_id`, `author_identifier`,
|
||||
`author_name`, `updated_by_identifier`, `status`, `published_at`)
|
||||
VALUES (?, ?, ?, ?, ?, NULLIF(?, 0), ?, ?, ?, ?, IF(? = 'published', CURRENT_TIMESTAMP, NULL))
|
||||
]], {
|
||||
id,
|
||||
article.title,
|
||||
article.body,
|
||||
article.excerpt,
|
||||
article.category,
|
||||
article.image_media_id or 0,
|
||||
actor.identifier,
|
||||
actor.name,
|
||||
actor.identifier,
|
||||
article.status,
|
||||
article.status,
|
||||
})
|
||||
local statements = {{
|
||||
query = [[
|
||||
INSERT INTO `sky_phone_weazel_articles`
|
||||
(`id`, `title`, `body`, `excerpt`, `category`, `image_media_id`, `author_identifier`,
|
||||
`author_name`, `updated_by_identifier`, `status`, `published_at`)
|
||||
VALUES (?, ?, ?, ?, ?, NULLIF(?, 0), ?, ?, ?, ?, IF(? = 'published', CURRENT_TIMESTAMP, NULL))
|
||||
]],
|
||||
params = {
|
||||
id,
|
||||
article.title,
|
||||
article.body,
|
||||
article.excerpt,
|
||||
article.category,
|
||||
article.image_media_id or 0,
|
||||
actor.identifier,
|
||||
actor.name,
|
||||
actor.identifier,
|
||||
article.status,
|
||||
article.status,
|
||||
},
|
||||
}}
|
||||
for position, media_id in ipairs(article.image_media_ids) do
|
||||
statements[#statements + 1] = {
|
||||
query = [[
|
||||
INSERT INTO `sky_phone_weazel_article_media` (`article_id`, `media_id`, `position`)
|
||||
VALUES (?, ?, ?)
|
||||
]],
|
||||
params = { id, media_id, position },
|
||||
}
|
||||
end
|
||||
if not Bridge.Database.Transaction(statements) then
|
||||
return { success = false, error = "request_failed" }
|
||||
end
|
||||
local created = load_article(id, true)
|
||||
if not created then
|
||||
error(("[sky_phone] Could not reload created Weazel News article '%s'."):format(id))
|
||||
@@ -517,8 +627,23 @@ Bridge.Callbacks.Register("sky_phone:weazel-news:update", function(source, data)
|
||||
if tonumber(current.revision) ~= revision then
|
||||
return { success = false, error = "revision_conflict" }
|
||||
end
|
||||
local retained_media_id = current.image_media_id and tonumber(current.image_media_id) or nil
|
||||
local article, validation_error = validate_article(source, data, retained_media_id)
|
||||
local retained_media_ids = {}
|
||||
local retained_cover_id = current.image_media_id and tonumber(current.image_media_id) or nil
|
||||
if retained_cover_id then
|
||||
retained_media_ids[retained_cover_id] = true
|
||||
end
|
||||
local retained_rows = Bridge.Database.Query([[
|
||||
SELECT `media_id`
|
||||
FROM `sky_phone_weazel_article_media`
|
||||
WHERE `article_id` = ?
|
||||
]], { data.id })
|
||||
for _, row in ipairs(retained_rows) do
|
||||
local media_id = tonumber(row.media_id)
|
||||
if media_id then
|
||||
retained_media_ids[media_id] = true
|
||||
end
|
||||
end
|
||||
local article, validation_error = validate_article(source, data, retained_media_ids)
|
||||
if not article then
|
||||
return { success = false, error = validation_error }
|
||||
end
|
||||
@@ -526,34 +651,80 @@ Bridge.Callbacks.Register("sky_phone:weazel-news:update", function(source, data)
|
||||
if not actor then
|
||||
return { success = false, error = "request_failed" }
|
||||
end
|
||||
local result = Bridge.Database.Query([[
|
||||
UPDATE `sky_phone_weazel_articles`
|
||||
SET `title` = ?, `body` = ?, `excerpt` = ?, `category` = ?, `image_media_id` = NULLIF(?, 0),
|
||||
`published_at` = CASE
|
||||
WHEN ? = 'draft' THEN NULL
|
||||
WHEN `status` = 'draft' THEN CURRENT_TIMESTAMP
|
||||
ELSE `published_at`
|
||||
END,
|
||||
`status` = ?, `updated_by_identifier` = ?, `revision` = `revision` + 1
|
||||
WHERE `id` = ? AND `revision` = ? AND `deleted_at` IS NULL
|
||||
]], {
|
||||
article.title,
|
||||
article.body,
|
||||
article.excerpt,
|
||||
article.category,
|
||||
article.image_media_id or 0,
|
||||
article.status,
|
||||
article.status,
|
||||
actor.identifier,
|
||||
data.id,
|
||||
revision,
|
||||
})
|
||||
if affected_rows(result) ~= 1 then
|
||||
return { success = false, error = "revision_conflict" }
|
||||
local mutation_rows = Bridge.Database.Query("SELECT UUID() AS `id`", {})
|
||||
local mutation_id = mutation_rows[1] and mutation_rows[1].id
|
||||
if not valid_uuid(mutation_id) then
|
||||
error("[sky_phone] Database did not generate a Weazel News mutation id.")
|
||||
end
|
||||
local mutation_token = "weazel:" .. mutation_id
|
||||
local next_revision = revision + 1
|
||||
local statements = {
|
||||
{
|
||||
query = [[
|
||||
UPDATE `sky_phone_weazel_articles`
|
||||
SET `title` = ?, `body` = ?, `excerpt` = ?, `category` = ?,
|
||||
`image_media_id` = NULLIF(?, 0),
|
||||
`published_at` = CASE
|
||||
WHEN ? = 'draft' THEN NULL
|
||||
WHEN `status` = 'draft' THEN CURRENT_TIMESTAMP
|
||||
ELSE `published_at`
|
||||
END,
|
||||
`status` = ?, `updated_by_identifier` = ?, `revision` = `revision` + 1
|
||||
WHERE `id` = ? AND `revision` = ? AND `deleted_at` IS NULL
|
||||
]],
|
||||
params = {
|
||||
article.title,
|
||||
article.body,
|
||||
article.excerpt,
|
||||
article.category,
|
||||
article.image_media_id or 0,
|
||||
article.status,
|
||||
article.status,
|
||||
mutation_token,
|
||||
data.id,
|
||||
revision,
|
||||
},
|
||||
},
|
||||
{
|
||||
query = [[
|
||||
DELETE relation
|
||||
FROM `sky_phone_weazel_article_media` relation
|
||||
JOIN `sky_phone_weazel_articles` article ON article.`id` = relation.`article_id`
|
||||
WHERE article.`id` = ? AND article.`revision` = ?
|
||||
AND article.`updated_by_identifier` = ?
|
||||
]],
|
||||
params = { data.id, next_revision, mutation_token },
|
||||
},
|
||||
}
|
||||
for position, media_id in ipairs(article.image_media_ids) do
|
||||
statements[#statements + 1] = {
|
||||
query = [[
|
||||
INSERT INTO `sky_phone_weazel_article_media` (`article_id`, `media_id`, `position`)
|
||||
SELECT article.`id`, ?, ?
|
||||
FROM `sky_phone_weazel_articles` article
|
||||
WHERE article.`id` = ? AND article.`revision` = ?
|
||||
AND article.`updated_by_identifier` = ? AND article.`deleted_at` IS NULL
|
||||
]],
|
||||
params = { media_id, position, data.id, next_revision, mutation_token },
|
||||
}
|
||||
end
|
||||
statements[#statements + 1] = {
|
||||
query = [[
|
||||
UPDATE `sky_phone_weazel_articles`
|
||||
SET `updated_by_identifier` = ?
|
||||
WHERE `id` = ? AND `revision` = ? AND `updated_by_identifier` = ?
|
||||
]],
|
||||
params = { actor.identifier, data.id, next_revision, mutation_token },
|
||||
}
|
||||
if not Bridge.Database.Transaction(statements) then
|
||||
return { success = false, error = "request_failed" }
|
||||
end
|
||||
local updated = load_article(data.id, true)
|
||||
if not updated then
|
||||
error(("[sky_phone] Could not reload updated Weazel News article '%s'."):format(data.id))
|
||||
return { success = false, error = "not_found" }
|
||||
end
|
||||
if not article_matches(updated, article, next_revision) then
|
||||
return { success = false, error = "revision_conflict" }
|
||||
end
|
||||
return { success = true, data = { article = updated } }
|
||||
end)
|
||||
|
||||
@@ -1244,3 +1244,15 @@ CREATE TABLE IF NOT EXISTS `sky_phone_weazel_articles` (
|
||||
KEY `idx_sky_phone_weazel_media` (`image_media_id`),
|
||||
FOREIGN KEY (`image_media_id`) REFERENCES `sky_phone_media` (`id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_weazel_article_media` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`article_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`media_id` BIGINT UNSIGNED NOT NULL,
|
||||
`position` TINYINT UNSIGNED NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_sky_phone_weazel_article_position` (`article_id`,`position`),
|
||||
UNIQUE KEY `uniq_sky_phone_weazel_article_media` (`article_id`,`media_id`),
|
||||
FOREIGN KEY (`article_id`) REFERENCES `sky_phone_weazel_articles` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`media_id`) REFERENCES `sky_phone_media` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
Reference in New Issue
Block a user