mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 00:01:29 +00:00
ADD - implement Weazel News app
This commit is contained in:
@@ -215,6 +215,26 @@ For a fresh manual database installation, import `sky_phone/sql/install.sql`. It
|
||||
|
||||
Framework, inventory, callback, notification, and database integrations live under `sky_phone/source/bridge`. The resource has no dependency on any other Sky resource.
|
||||
|
||||
## Weazel News app
|
||||
|
||||
The built-in Weazel News app publishes articles for every phone user and exposes its editorial
|
||||
desk only to server-authorized jobs. Configure the job-to-minimum-grade map in
|
||||
`sky_phone/config/weazel_news.lua`; unlisted jobs remain read-only:
|
||||
|
||||
```lua
|
||||
AllowedJobs = {
|
||||
weazel = 0,
|
||||
reporter = 2,
|
||||
}
|
||||
```
|
||||
|
||||
Every create, update, and delete request rechecks the player's current framework job and grade on
|
||||
the server. Authorized editorial jobs share the newsroom and can manage drafts and published
|
||||
articles. Cover images must come from the current phone's Gallery, changes use revision checks, and
|
||||
deletion is retained as an audit-safe soft delete. Runtime migration creates
|
||||
`sky_phone_weazel_articles` automatically; fresh installations receive the same schema through
|
||||
`sky_phone/sql/install.sql`.
|
||||
|
||||
## Radio app
|
||||
|
||||
The built-in Radio app supports a primary frequency, volume, recent channels, participant lists, automatic rejoin, join/leave notifications, and an optional service number. YACA and SaltyChat support the configured secondary frequency; PMA Voice exposes one radio channel, so the secondary input is hidden automatically.
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="Weazel News">
|
||||
<defs>
|
||||
<linearGradient id="weazel-news-background" x1="18" y1="10" x2="112" y2="122" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#171717"/>
|
||||
<stop offset="1" stop-color="#050505"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="weazel-news-accent" x1="26" y1="24" x2="103" y2="105" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#ff4a50"/>
|
||||
<stop offset="1" stop-color="#c9101c"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="128" height="128" rx="28" fill="url(#weazel-news-background)"/>
|
||||
<path d="M28 31h72a8 8 0 0 1 8 8v58a8 8 0 0 1-8 8H28a8 8 0 0 1-8-8V39a8 8 0 0 1 8-8Z" fill="#f4f4f1"/>
|
||||
<path d="M20 46h88v-7a8 8 0 0 0-8-8H28a8 8 0 0 0-8 8v7Z" fill="url(#weazel-news-accent)"/>
|
||||
<path d="M31 58h34v26H31z" fill="#181818"/>
|
||||
<path d="M72 59h25M72 68h25M72 77h20M31 92h66" fill="none" stroke="#747474" stroke-width="5" stroke-linecap="round"/>
|
||||
<path d="m38 63 6 16 5-12 5 12 6-16" fill="none" stroke="#f4f4f1" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="96" cy="38" r="3" fill="#fff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -1,4 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Newspaper } from 'lucide-vue-next'
|
||||
|
||||
import { isPhoneAppId, PHONE_APPS } from './apps'
|
||||
describe('app registry', () => {
|
||||
it('has unique ids and routes with the reference dock order', () => {
|
||||
@@ -58,6 +60,16 @@ describe('app registry', () => {
|
||||
labelKey: 'Apps.companies.name',
|
||||
route: '/apps/companies',
|
||||
})
|
||||
expect(PHONE_APPS.find((app) => app.id === 'weazel-news')).toMatchObject({
|
||||
category: 'social',
|
||||
dockOrder: null,
|
||||
gridOrder: 29,
|
||||
labelKey: 'Apps.weazelNews.name',
|
||||
route: '/apps/weazel-news',
|
||||
})
|
||||
expect(PHONE_APPS.find((app) => app.id === 'weazel-news')?.icon).toBe(
|
||||
Newspaper,
|
||||
)
|
||||
expect(PHONE_APPS.find((app) => app.id === 'music')).toMatchObject({
|
||||
category: 'utilities',
|
||||
gridOrder: 26,
|
||||
@@ -135,6 +147,7 @@ describe('app registry', () => {
|
||||
expect(isPhoneAppId('skyride')).toBe(true)
|
||||
expect(isPhoneAppId('music')).toBe(true)
|
||||
expect(isPhoneAppId('companies')).toBe(true)
|
||||
expect(isPhoneAppId('weazel-news')).toBe(true)
|
||||
expect(
|
||||
PHONE_APPS.filter((app) => app.category === 'games').map((app) => app.id),
|
||||
).toEqual([
|
||||
@@ -151,6 +164,7 @@ describe('app registry', () => {
|
||||
(app) => app.id,
|
||||
),
|
||||
).toEqual([
|
||||
'weazel-news',
|
||||
'picstagram',
|
||||
'feather',
|
||||
'fliptok',
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
ReceiptText,
|
||||
UsersRound,
|
||||
Building2,
|
||||
Newspaper,
|
||||
} from 'lucide-vue-next'
|
||||
import { defineAsyncComponent, markRaw, shallowReactive } from 'vue'
|
||||
|
||||
@@ -72,6 +73,7 @@ import musicIcon from '@/assets/img/app-icons/music.svg'
|
||||
import featherIcon from '@/assets/img/app-icons/feather.svg'
|
||||
import crewLinkIcon from '@/assets/img/app-icons/crewlink.svg'
|
||||
import companiesIcon from '@/assets/img/app-icons/companies.svg'
|
||||
import weazelNewsIcon from '@/assets/img/app-icons/weazel-news.svg'
|
||||
import type {
|
||||
BuiltinPhoneAppDefinition,
|
||||
BuiltinPhoneAppId,
|
||||
@@ -83,6 +85,20 @@ import type {
|
||||
} from '@/types/apps'
|
||||
|
||||
export const PHONE_APPS = shallowReactive<PhoneAppDefinition[]>([
|
||||
{
|
||||
category: 'social',
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/weazel-news-app.vue')),
|
||||
),
|
||||
dockOrder: null,
|
||||
gridOrder: 29,
|
||||
icon: markRaw(Newspaper),
|
||||
iconClass: 'app-icon--weazel-news',
|
||||
iconImage: weazelNewsIcon,
|
||||
id: 'weazel-news',
|
||||
labelKey: 'Apps.weazelNews.name',
|
||||
route: '/apps/weazel-news',
|
||||
},
|
||||
{
|
||||
category: 'utilities',
|
||||
component: markRaw(
|
||||
|
||||
@@ -414,6 +414,136 @@ const defaultLocales: LocaleTree = {
|
||||
request_failed: 'EasyShare is temporarily unavailable.',
|
||||
},
|
||||
},
|
||||
weazelNews: {
|
||||
name: 'Weazel News',
|
||||
brand: 'WEAZEL NEWS',
|
||||
navigation: 'Weazel News navigation',
|
||||
tabs: {
|
||||
home: 'Home',
|
||||
categories: 'Categories',
|
||||
search: 'Search',
|
||||
editorial: 'Editorial',
|
||||
},
|
||||
back: 'Back',
|
||||
retry: 'Try Again',
|
||||
loadMore: 'Load More',
|
||||
home: {
|
||||
eyebrow: 'Los Santos, live',
|
||||
latest: 'Latest News',
|
||||
},
|
||||
categories: {
|
||||
title: 'Categories',
|
||||
subtitle: 'Browse every story from the Weazel News desk.',
|
||||
all: 'All Stories',
|
||||
official: 'Official Notices',
|
||||
events: 'Events & Leisure',
|
||||
jobs: 'Jobs',
|
||||
news: 'News & Events',
|
||||
business: 'Business',
|
||||
},
|
||||
search: {
|
||||
placeholder: 'Search articles...',
|
||||
title: 'Search Weazel News',
|
||||
emptyTitle: 'No Articles Found',
|
||||
emptyBody: 'Try another headline, topic, or category.',
|
||||
},
|
||||
states: {
|
||||
loading: 'Loading Weazel News...',
|
||||
emptyTitle: 'No News Yet',
|
||||
emptyBody:
|
||||
'The Weazel News desk has not published any articles here yet.',
|
||||
errorTitle: 'Weazel News Is Unavailable',
|
||||
readOnlyTitle: 'Read-only Access',
|
||||
readOnlyBody:
|
||||
'Your current job can read Weazel News, but cannot manage articles.',
|
||||
noManagedTitle: 'No Editorial Articles',
|
||||
noManagedBody:
|
||||
'Create an article or change the current editorial filter.',
|
||||
},
|
||||
article: {
|
||||
readMore: 'Read Article',
|
||||
byline: 'By {author}',
|
||||
updated: 'Updated {date}',
|
||||
published: 'Published {date}',
|
||||
coverAlt: 'Cover image for {title}',
|
||||
},
|
||||
editorial: {
|
||||
title: 'Editorial Desk',
|
||||
subtitle: 'Create, publish, and maintain Weazel News articles.',
|
||||
newArticle: 'New Article',
|
||||
published: 'Published',
|
||||
drafts: 'Drafts',
|
||||
all: 'All',
|
||||
jobAccess: 'Signed in as {job}',
|
||||
edit: 'Edit Article',
|
||||
delete: 'Delete Article',
|
||||
},
|
||||
composer: {
|
||||
newTitle: 'New Article',
|
||||
editTitle: 'Edit Article',
|
||||
title: 'Headline',
|
||||
titlePlaceholder: 'Write a clear headline',
|
||||
body: 'Article',
|
||||
bodyPlaceholder: 'Write the full story...',
|
||||
category: 'Category',
|
||||
status: 'Publication Status',
|
||||
cover: 'Cover Image',
|
||||
chooseCover: 'Choose from Gallery',
|
||||
changeCover: 'Change Cover',
|
||||
removeCover: 'Remove Cover',
|
||||
coverAlt: 'Selected article cover',
|
||||
publish: 'Publish Article',
|
||||
saveDraft: 'Save Draft',
|
||||
saveChanges: 'Save Changes',
|
||||
statusPublished: 'Published',
|
||||
statusDraft: 'Draft',
|
||||
},
|
||||
delete: {
|
||||
title: 'Delete Article?',
|
||||
body: 'This article will be removed from Weazel News.',
|
||||
cancel: 'Cancel',
|
||||
confirm: 'Delete',
|
||||
},
|
||||
feedback: {
|
||||
created: 'Article created.',
|
||||
updated: 'Article updated.',
|
||||
deleted: 'Article deleted.',
|
||||
},
|
||||
accessibility: {
|
||||
articleList: 'Weazel News articles',
|
||||
categoryList: 'News categories',
|
||||
editorialList: 'Editorial articles',
|
||||
openArticle: 'Open {title}',
|
||||
openCategory: 'Open {category}',
|
||||
openEditorial: 'Open the editorial desk',
|
||||
newArticle: 'Create a new article',
|
||||
editArticle: 'Edit {title}',
|
||||
deleteArticle: 'Delete {title}',
|
||||
search: 'Search Weazel News',
|
||||
clearSearch: 'Clear article search',
|
||||
coverPreview: 'Article cover preview',
|
||||
removeCover: 'Remove the selected cover image',
|
||||
status: 'Article status: {status}',
|
||||
},
|
||||
errors: {
|
||||
feature_disabled: 'Weazel News is currently disabled.',
|
||||
invalid_article: 'Enter a valid headline, article, and category.',
|
||||
invalid_draft:
|
||||
'Enter a headline and article text before saving the draft.',
|
||||
invalid_publish: 'Enter a headline and article text before publishing.',
|
||||
invalid_request: 'Check the article details and try again.',
|
||||
invalid_image: 'Choose a valid photo from this phone.',
|
||||
invalid_attachment: 'Choose a valid photo from this phone.',
|
||||
not_authorized: 'Your current job cannot manage Weazel News articles.',
|
||||
article_not_found: 'This article is no longer available.',
|
||||
not_found: 'This article is no longer available.',
|
||||
revision_conflict:
|
||||
'This article changed on another device. Reload it and try again.',
|
||||
rate_limited: 'Too many requests. Try again shortly.',
|
||||
request_failed: 'Weazel News could not complete the request.',
|
||||
default: 'Weazel News is temporarily unavailable.',
|
||||
},
|
||||
},
|
||||
companies: companiesFallbackLocales,
|
||||
crewlink: {
|
||||
name: 'CrewLink',
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useWeazelNewsStore } from '@/stores/weazel-news'
|
||||
import type {
|
||||
WeazelNewsArticle,
|
||||
WeazelNewsArticleDraft,
|
||||
} from '@/types/weazel-news'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
|
||||
vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() }))
|
||||
|
||||
const mockNuiCall = vi.mocked(nuiCall)
|
||||
|
||||
const article: WeazelNewsArticle = {
|
||||
authorName: 'Jane Doe',
|
||||
body: 'The full article body.',
|
||||
category: 'news',
|
||||
createdAt: 1_754_000_000,
|
||||
excerpt: 'The article excerpt.',
|
||||
id: '7d28b252-0532-4869-9512-e313e34d2fb8',
|
||||
imageMediaId: 12,
|
||||
imageUrl: 'https://media.invalid/weazel/article.webp',
|
||||
publishedAt: 1_754_000_100,
|
||||
revision: 4,
|
||||
status: 'published',
|
||||
title: 'Breaking news',
|
||||
updatedAt: 1_754_000_100,
|
||||
}
|
||||
|
||||
const draft: WeazelNewsArticleDraft = {
|
||||
body: 'Updated body.',
|
||||
category: 'business',
|
||||
imageMediaId: null,
|
||||
status: 'draft',
|
||||
title: 'Updated headline',
|
||||
}
|
||||
|
||||
describe('Weazel News store', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mockNuiCall.mockReset()
|
||||
})
|
||||
|
||||
it('loads the server-owned management context and category counts', async () => {
|
||||
mockNuiCall.mockResolvedValueOnce({
|
||||
data: {
|
||||
canManage: true,
|
||||
categories: [
|
||||
{ count: 3, id: 'news' },
|
||||
{ count: 1, id: 'jobs' },
|
||||
],
|
||||
jobGradeLabel: 'Editor',
|
||||
jobLabel: 'Weazel News',
|
||||
},
|
||||
success: true,
|
||||
})
|
||||
const store = useWeazelNewsStore()
|
||||
|
||||
expect(await store.loadContext()).toBe(true)
|
||||
expect(mockNuiCall).toHaveBeenCalledWith('weazel-news:context')
|
||||
expect(store.context?.canManage).toBe(true)
|
||||
expect(store.context?.categories[0]).toEqual({ count: 3, id: 'news' })
|
||||
})
|
||||
|
||||
it('loads and deduplicates appended public pages', async () => {
|
||||
mockNuiCall
|
||||
.mockResolvedValueOnce({
|
||||
data: { hasMore: true, items: [article] },
|
||||
success: true,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
hasMore: false,
|
||||
items: [
|
||||
{ ...article, revision: 5, title: 'Canonical headline' },
|
||||
{ ...article, id: 'second-article' },
|
||||
],
|
||||
},
|
||||
success: true,
|
||||
})
|
||||
const store = useWeazelNewsStore()
|
||||
|
||||
await store.loadPublic({ category: 'news', search: 'breaking' })
|
||||
await store.loadPublic({
|
||||
append: true,
|
||||
category: 'news',
|
||||
search: 'breaking',
|
||||
})
|
||||
|
||||
expect(mockNuiCall).toHaveBeenNthCalledWith(1, 'weazel-news:list', {
|
||||
category: 'news',
|
||||
offset: 0,
|
||||
search: 'breaking',
|
||||
})
|
||||
expect(mockNuiCall).toHaveBeenNthCalledWith(2, 'weazel-news:list', {
|
||||
category: 'news',
|
||||
offset: 1,
|
||||
search: 'breaking',
|
||||
})
|
||||
expect(store.publicItems).toHaveLength(2)
|
||||
expect(store.publicItems[0]?.title).toBe('Canonical headline')
|
||||
expect(store.publicHasMore).toBe(false)
|
||||
})
|
||||
|
||||
it('loads paged management results including drafts', async () => {
|
||||
mockNuiCall.mockResolvedValueOnce({
|
||||
data: { hasMore: false, items: [{ ...article, status: 'draft' }] },
|
||||
success: true,
|
||||
})
|
||||
const store = useWeazelNewsStore()
|
||||
|
||||
expect(
|
||||
await store.loadManaged('draft', { offset: 20, search: 'city' }),
|
||||
).toBe(true)
|
||||
expect(mockNuiCall).toHaveBeenCalledWith('weazel-news:manage-list', {
|
||||
offset: 20,
|
||||
search: 'city',
|
||||
status: 'draft',
|
||||
})
|
||||
expect(store.managedItems[0]?.status).toBe('draft')
|
||||
})
|
||||
|
||||
it('loads a canonical article for public or management detail', async () => {
|
||||
mockNuiCall.mockResolvedValueOnce({
|
||||
data: { article },
|
||||
success: true,
|
||||
})
|
||||
const store = useWeazelNewsStore()
|
||||
|
||||
expect(await store.loadArticle(article.id, true)).toBe(true)
|
||||
expect(mockNuiCall).toHaveBeenCalledWith('weazel-news:get', {
|
||||
id: article.id,
|
||||
manage: true,
|
||||
})
|
||||
expect(store.selected).toEqual(article)
|
||||
})
|
||||
|
||||
it('normalizes a direct canonical create response', async () => {
|
||||
const created = { ...article, id: 'created-article', revision: 1 }
|
||||
mockNuiCall.mockResolvedValueOnce({ data: created, success: true })
|
||||
const store = useWeazelNewsStore()
|
||||
|
||||
expect(await store.create({ ...draft, status: 'published' })).toEqual(
|
||||
created,
|
||||
)
|
||||
expect(mockNuiCall).toHaveBeenCalledWith('weazel-news:create', {
|
||||
...draft,
|
||||
status: 'published',
|
||||
})
|
||||
expect(store.managedItems).toEqual([created])
|
||||
expect(store.publicItems).toEqual([created])
|
||||
})
|
||||
|
||||
it('sends the optimistic revision and applies the canonical update', async () => {
|
||||
const canonical = {
|
||||
...article,
|
||||
...draft,
|
||||
excerpt: 'Updated excerpt.',
|
||||
revision: 5,
|
||||
updatedAt: 1_754_100_000,
|
||||
}
|
||||
mockNuiCall.mockResolvedValueOnce({
|
||||
data: { article: canonical },
|
||||
success: true,
|
||||
})
|
||||
const store = useWeazelNewsStore()
|
||||
store.publicItems = [article]
|
||||
store.managedItems = [article]
|
||||
store.selected = article
|
||||
|
||||
expect(await store.update(article, draft)).toEqual(canonical)
|
||||
expect(mockNuiCall).toHaveBeenCalledWith('weazel-news:update', {
|
||||
...draft,
|
||||
id: article.id,
|
||||
revision: 4,
|
||||
})
|
||||
expect(store.selected).toEqual(canonical)
|
||||
expect(store.managedItems).toEqual([canonical])
|
||||
expect(store.publicItems).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps local articles unchanged after a revision conflict', async () => {
|
||||
mockNuiCall.mockResolvedValueOnce({
|
||||
error: 'revision_conflict',
|
||||
success: false,
|
||||
})
|
||||
const store = useWeazelNewsStore()
|
||||
store.managedItems = [article]
|
||||
|
||||
expect(await store.update(article, draft)).toBeNull()
|
||||
expect(store.managedItems).toEqual([article])
|
||||
expect(store.error).toBe('revision_conflict')
|
||||
})
|
||||
|
||||
it('deletes by id and revision only after server confirmation', async () => {
|
||||
mockNuiCall.mockResolvedValueOnce({ success: true })
|
||||
const store = useWeazelNewsStore()
|
||||
store.publicItems = [article]
|
||||
store.managedItems = [article]
|
||||
store.selected = article
|
||||
|
||||
expect(await store.remove(article)).toBe(true)
|
||||
expect(mockNuiCall).toHaveBeenCalledWith('weazel-news:delete', {
|
||||
id: article.id,
|
||||
revision: article.revision,
|
||||
})
|
||||
expect(store.publicItems).toEqual([])
|
||||
expect(store.managedItems).toEqual([])
|
||||
expect(store.selected).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,283 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import type {
|
||||
WeazelNewsArticle,
|
||||
WeazelNewsArticleDraft,
|
||||
WeazelNewsArticleResponse,
|
||||
WeazelNewsArticleSummary,
|
||||
WeazelNewsContext,
|
||||
WeazelNewsListResponse,
|
||||
WeazelNewsManagedListOptions,
|
||||
WeazelNewsManageStatus,
|
||||
WeazelNewsPublicListOptions,
|
||||
} from '@/types/weazel-news'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
|
||||
type ArticleMutationResponse = WeazelNewsArticle | WeazelNewsArticleResponse
|
||||
|
||||
function readArticle(
|
||||
data: ArticleMutationResponse | undefined,
|
||||
): WeazelNewsArticle | null {
|
||||
if (!data) return null
|
||||
return 'article' in data ? data.article : data
|
||||
}
|
||||
|
||||
function replaceArticle(
|
||||
items: WeazelNewsArticleSummary[],
|
||||
article: WeazelNewsArticleSummary,
|
||||
): WeazelNewsArticleSummary[] {
|
||||
return items.map((item) => (item.id === article.id ? article : item))
|
||||
}
|
||||
|
||||
function mergeArticles(
|
||||
current: WeazelNewsArticleSummary[],
|
||||
incoming: WeazelNewsArticleSummary[],
|
||||
): WeazelNewsArticleSummary[] {
|
||||
const merged = new Map(current.map((article) => [article.id, article]))
|
||||
for (const article of incoming) merged.set(article.id, article)
|
||||
return [...merged.values()]
|
||||
}
|
||||
|
||||
export const useWeazelNewsStore = defineStore('weazel-news', {
|
||||
state: () => ({
|
||||
context: null as WeazelNewsContext | null,
|
||||
contextError: '',
|
||||
contextLoadSequence: 0,
|
||||
contextLoading: false,
|
||||
detailError: '',
|
||||
detailLoadSequence: 0,
|
||||
detailLoading: false,
|
||||
error: '',
|
||||
loading: false,
|
||||
managedError: '',
|
||||
managedLoadSequence: 0,
|
||||
managedLoading: false,
|
||||
managedHasMore: false,
|
||||
managedItems: [] as WeazelNewsArticleSummary[],
|
||||
mutating: false,
|
||||
publicHasMore: false,
|
||||
publicError: '',
|
||||
publicLoadSequence: 0,
|
||||
publicLoading: false,
|
||||
publicItems: [] as WeazelNewsArticleSummary[],
|
||||
selected: null as WeazelNewsArticle | null,
|
||||
}),
|
||||
actions: {
|
||||
async loadContext(): Promise<boolean> {
|
||||
const requestSequence = ++this.contextLoadSequence
|
||||
this.contextLoading = true
|
||||
this.loading = true
|
||||
const response = await nuiCall<WeazelNewsContext>('weazel-news:context')
|
||||
if (requestSequence !== this.contextLoadSequence) return false
|
||||
this.contextLoading = false
|
||||
this.loading =
|
||||
this.publicLoading || this.managedLoading || this.detailLoading
|
||||
if (!response.success || !response.data) {
|
||||
this.contextError = response.error ?? 'request_failed'
|
||||
this.error = this.contextError
|
||||
return false
|
||||
}
|
||||
this.context = response.data
|
||||
this.contextError = ''
|
||||
this.error = ''
|
||||
return true
|
||||
},
|
||||
async loadPublic(
|
||||
options: WeazelNewsPublicListOptions = {},
|
||||
): Promise<boolean> {
|
||||
const append = options.append ?? false
|
||||
const requestSequence = ++this.publicLoadSequence
|
||||
this.publicLoading = true
|
||||
this.loading = true
|
||||
const response = await nuiCall<WeazelNewsListResponse>(
|
||||
'weazel-news:list',
|
||||
{
|
||||
category: options.category ?? null,
|
||||
offset: options.offset ?? (append ? this.publicItems.length : 0),
|
||||
search: options.search ?? '',
|
||||
},
|
||||
)
|
||||
if (requestSequence !== this.publicLoadSequence) return false
|
||||
this.publicLoading = false
|
||||
this.loading =
|
||||
this.contextLoading || this.managedLoading || this.detailLoading
|
||||
if (!response.success || !response.data) {
|
||||
this.publicError = response.error ?? 'request_failed'
|
||||
this.error = this.publicError
|
||||
return false
|
||||
}
|
||||
this.publicItems = append
|
||||
? mergeArticles(this.publicItems, response.data.items)
|
||||
: response.data.items
|
||||
this.publicHasMore = response.data.hasMore
|
||||
this.publicError = ''
|
||||
this.error = ''
|
||||
return true
|
||||
},
|
||||
async loadManaged(
|
||||
status: WeazelNewsManageStatus,
|
||||
options: WeazelNewsManagedListOptions = {},
|
||||
): Promise<boolean> {
|
||||
const append = options.append ?? false
|
||||
const requestSequence = ++this.managedLoadSequence
|
||||
this.managedLoading = true
|
||||
this.loading = true
|
||||
const response = await nuiCall<WeazelNewsListResponse>(
|
||||
'weazel-news:manage-list',
|
||||
{
|
||||
offset: options.offset ?? (append ? this.managedItems.length : 0),
|
||||
search: options.search ?? '',
|
||||
status,
|
||||
},
|
||||
)
|
||||
if (requestSequence !== this.managedLoadSequence) return false
|
||||
this.managedLoading = false
|
||||
this.loading =
|
||||
this.contextLoading || this.publicLoading || this.detailLoading
|
||||
if (!response.success || !response.data) {
|
||||
this.managedError = response.error ?? 'request_failed'
|
||||
this.error = this.managedError
|
||||
return false
|
||||
}
|
||||
this.managedItems = append
|
||||
? mergeArticles(this.managedItems, response.data.items)
|
||||
: response.data.items
|
||||
this.managedHasMore = response.data.hasMore
|
||||
this.managedError = ''
|
||||
this.error = ''
|
||||
return true
|
||||
},
|
||||
async loadArticle(id: string, manage = false): Promise<boolean> {
|
||||
const requestSequence = ++this.detailLoadSequence
|
||||
this.detailLoading = true
|
||||
this.loading = true
|
||||
const response = await nuiCall<ArticleMutationResponse>(
|
||||
'weazel-news:get',
|
||||
{
|
||||
id,
|
||||
manage,
|
||||
},
|
||||
)
|
||||
if (requestSequence !== this.detailLoadSequence) return false
|
||||
this.detailLoading = false
|
||||
this.loading =
|
||||
this.contextLoading || this.publicLoading || this.managedLoading
|
||||
const article = response.success ? readArticle(response.data) : null
|
||||
if (!article) {
|
||||
this.detailError = response.error ?? 'request_failed'
|
||||
this.error = this.detailError
|
||||
return false
|
||||
}
|
||||
this.selected = article
|
||||
this.detailError = ''
|
||||
this.publicItems = replaceArticle(this.publicItems, article)
|
||||
this.managedItems = replaceArticle(this.managedItems, article)
|
||||
this.error = ''
|
||||
return true
|
||||
},
|
||||
async create(
|
||||
draft: WeazelNewsArticleDraft,
|
||||
): Promise<WeazelNewsArticle | null> {
|
||||
this.publicLoadSequence += 1
|
||||
this.managedLoadSequence += 1
|
||||
this.detailLoadSequence += 1
|
||||
this.publicLoading = false
|
||||
this.managedLoading = false
|
||||
this.detailLoading = false
|
||||
this.loading = this.contextLoading
|
||||
this.mutating = true
|
||||
const response = await nuiCall<ArticleMutationResponse>(
|
||||
'weazel-news:create',
|
||||
draft,
|
||||
)
|
||||
this.mutating = false
|
||||
const article = response.success ? readArticle(response.data) : null
|
||||
if (!article) {
|
||||
this.error = response.error ?? 'request_failed'
|
||||
return null
|
||||
}
|
||||
this.selected = article
|
||||
this.managedItems = [
|
||||
article,
|
||||
...this.managedItems.filter((item) => item.id !== article.id),
|
||||
]
|
||||
if (article.status === 'published') {
|
||||
this.publicItems = [
|
||||
article,
|
||||
...this.publicItems.filter((item) => item.id !== article.id),
|
||||
]
|
||||
}
|
||||
this.error = ''
|
||||
return article
|
||||
},
|
||||
async update(
|
||||
article: WeazelNewsArticle,
|
||||
draft: WeazelNewsArticleDraft,
|
||||
): Promise<WeazelNewsArticle | null> {
|
||||
this.publicLoadSequence += 1
|
||||
this.managedLoadSequence += 1
|
||||
this.detailLoadSequence += 1
|
||||
this.publicLoading = false
|
||||
this.managedLoading = false
|
||||
this.detailLoading = false
|
||||
this.loading = this.contextLoading
|
||||
this.mutating = true
|
||||
const response = await nuiCall<ArticleMutationResponse>(
|
||||
'weazel-news:update',
|
||||
{
|
||||
...draft,
|
||||
id: article.id,
|
||||
revision: article.revision,
|
||||
},
|
||||
)
|
||||
this.mutating = false
|
||||
const canonical = response.success ? readArticle(response.data) : null
|
||||
if (!canonical) {
|
||||
this.error = response.error ?? 'request_failed'
|
||||
return null
|
||||
}
|
||||
if (this.selected?.id === canonical.id) this.selected = canonical
|
||||
this.managedItems = replaceArticle(this.managedItems, canonical)
|
||||
this.publicItems =
|
||||
canonical.status === 'published'
|
||||
? [
|
||||
canonical,
|
||||
...this.publicItems.filter((item) => item.id !== canonical.id),
|
||||
].sort(
|
||||
(left, right) =>
|
||||
(right.publishedAt ?? 0) - (left.publishedAt ?? 0),
|
||||
)
|
||||
: this.publicItems.filter((item) => item.id !== canonical.id)
|
||||
this.error = ''
|
||||
return canonical
|
||||
},
|
||||
async remove(article: WeazelNewsArticle): Promise<boolean> {
|
||||
this.publicLoadSequence += 1
|
||||
this.managedLoadSequence += 1
|
||||
this.detailLoadSequence += 1
|
||||
this.publicLoading = false
|
||||
this.managedLoading = false
|
||||
this.detailLoading = false
|
||||
this.loading = this.contextLoading
|
||||
this.mutating = true
|
||||
const response = await nuiCall('weazel-news:delete', {
|
||||
id: article.id,
|
||||
revision: article.revision,
|
||||
})
|
||||
this.mutating = false
|
||||
if (!response.success) {
|
||||
this.error = response.error ?? 'request_failed'
|
||||
return false
|
||||
}
|
||||
this.publicItems = this.publicItems.filter(
|
||||
(item) => item.id !== article.id,
|
||||
)
|
||||
this.managedItems = this.managedItems.filter(
|
||||
(item) => item.id !== article.id,
|
||||
)
|
||||
if (this.selected?.id === article.id) this.selected = null
|
||||
this.error = ''
|
||||
return true
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -37,6 +37,7 @@ export type BuiltinPhoneAppId =
|
||||
| 'feather'
|
||||
| 'crewlink'
|
||||
| 'companies'
|
||||
| 'weazel-news'
|
||||
|
||||
declare const externalPhoneAppId: unique symbol
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
export const WEAZEL_NEWS_CATEGORY_IDS = [
|
||||
'official',
|
||||
'events',
|
||||
'jobs',
|
||||
'news',
|
||||
'business',
|
||||
] as const
|
||||
|
||||
export const WEAZEL_NEWS_ARTICLE_STATUSES = ['draft', 'published'] as const
|
||||
|
||||
export type WeazelNewsCategoryId = (typeof WEAZEL_NEWS_CATEGORY_IDS)[number]
|
||||
|
||||
export type WeazelNewsArticleStatus =
|
||||
(typeof WEAZEL_NEWS_ARTICLE_STATUSES)[number]
|
||||
|
||||
export type WeazelNewsManageStatus = 'all' | WeazelNewsArticleStatus
|
||||
|
||||
export type WeazelNewsCategorySummary = {
|
||||
count: number
|
||||
id: WeazelNewsCategoryId
|
||||
}
|
||||
|
||||
export type WeazelNewsArticle = {
|
||||
authorName: string
|
||||
body: string
|
||||
category: WeazelNewsCategoryId
|
||||
createdAt: number
|
||||
excerpt: string
|
||||
id: string
|
||||
imageMediaId?: number | null
|
||||
imageUrl?: string | null
|
||||
publishedAt?: number | null
|
||||
revision: number
|
||||
status: WeazelNewsArticleStatus
|
||||
title: string
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export type WeazelNewsArticleSummary = Omit<WeazelNewsArticle, 'body'>
|
||||
|
||||
export type WeazelNewsArticleDraft = {
|
||||
body: string
|
||||
category: WeazelNewsCategoryId
|
||||
imageMediaId: number | null
|
||||
status: WeazelNewsArticleStatus
|
||||
title: string
|
||||
}
|
||||
|
||||
export type WeazelNewsContext = {
|
||||
canManage: boolean
|
||||
categories: WeazelNewsCategorySummary[]
|
||||
jobGradeLabel?: string
|
||||
jobLabel?: string
|
||||
}
|
||||
|
||||
export type WeazelNewsListResponse = {
|
||||
hasMore: boolean
|
||||
items: WeazelNewsArticleSummary[]
|
||||
}
|
||||
|
||||
export type WeazelNewsPublicListOptions = {
|
||||
append?: boolean
|
||||
category?: WeazelNewsCategoryId | null
|
||||
offset?: number
|
||||
search?: string
|
||||
}
|
||||
|
||||
export type WeazelNewsManagedListOptions = {
|
||||
append?: boolean
|
||||
offset?: number
|
||||
search?: string
|
||||
}
|
||||
|
||||
export type WeazelNewsArticleResponse = {
|
||||
article: WeazelNewsArticle
|
||||
}
|
||||
@@ -87,6 +87,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record<
|
||||
'neon-drop': { enabled: true, sounds: true },
|
||||
citymarkt: { enabled: true, sounds: true },
|
||||
companies: { enabled: true, sounds: true },
|
||||
'weazel-news': { enabled: true, sounds: true },
|
||||
'local-pages': { enabled: true, sounds: true },
|
||||
picstagram: { enabled: true, sounds: true },
|
||||
fliptok: { enabled: true, sounds: true },
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2336,6 +2336,194 @@ let mockMedia = [
|
||||
url: 'https://picsum.photos/seed/sky-phone-5/800/600',
|
||||
},
|
||||
]
|
||||
|
||||
const weazelNewsCategoryIds = ['official', 'events', 'jobs', 'news', 'business']
|
||||
let weazelNewsSequence = 8
|
||||
let weazelNewsArticles = [
|
||||
{
|
||||
id: '34c0ec54-bfb1-4ad7-81da-000000000001',
|
||||
title: 'Port Authority announces temporary harbor restrictions',
|
||||
body: 'The Port Authority has announced temporary navigation restrictions around the southern harbor while maintenance crews inspect the main shipping channel. Commercial operators should follow marked diversion routes and expect short delays through the afternoon. Emergency traffic will continue without interruption.',
|
||||
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,
|
||||
authorName: 'Avery Brooks',
|
||||
createdAt: Date.now() - 35 * 60 * 1000,
|
||||
updatedAt: Date.now() - 28 * 60 * 1000,
|
||||
publishedAt: Date.now() - 30 * 60 * 1000,
|
||||
status: 'published',
|
||||
revision: 2,
|
||||
},
|
||||
{
|
||||
id: '34c0ec54-bfb1-4ad7-81da-000000000002',
|
||||
title: 'Vinewood summer festival opens this weekend',
|
||||
body: "Vinewood Boulevard will welcome food stands, live performers, and classic cars during this weekend's summer festival. Organizers recommend using public parking near the eastern entrance and arriving early for the evening concert. The event is free and runs from noon until late.",
|
||||
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,
|
||||
authorName: 'Maya Chen',
|
||||
createdAt: Date.now() - 2 * 60 * 60 * 1000,
|
||||
updatedAt: Date.now() - 2 * 60 * 60 * 1000,
|
||||
publishedAt: Date.now() - 2 * 60 * 60 * 1000,
|
||||
status: 'published',
|
||||
revision: 1,
|
||||
},
|
||||
{
|
||||
id: '34c0ec54-bfb1-4ad7-81da-000000000003',
|
||||
title: 'City services expand recruitment drive',
|
||||
body: 'Several city departments have opened a coordinated recruitment drive for new staff. Positions are available across emergency response, transport, and public administration. Applicants should review individual department requirements before attending the recruitment office at City Hall.',
|
||||
excerpt:
|
||||
'City departments are recruiting new staff across emergency response, transport, and public administration.',
|
||||
category: 'jobs',
|
||||
imageUrl: null,
|
||||
imageMediaId: null,
|
||||
authorName: 'Jordan Hayes',
|
||||
createdAt: Date.now() - 4 * 60 * 60 * 1000,
|
||||
updatedAt: Date.now() - 3 * 60 * 60 * 1000,
|
||||
publishedAt: Date.now() - 3 * 60 * 60 * 1000,
|
||||
status: 'published',
|
||||
revision: 2,
|
||||
},
|
||||
{
|
||||
id: '34c0ec54-bfb1-4ad7-81da-000000000004',
|
||||
title: 'Traffic returns to normal after Del Perro closure',
|
||||
body: 'Traffic is moving normally again through Del Perro after crews cleared an earlier road obstruction. Police have reopened every lane and removed the temporary diversion signs. Drivers may still encounter brief congestion while the remaining queue disperses.',
|
||||
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,
|
||||
authorName: 'Avery Brooks',
|
||||
createdAt: Date.now() - 7 * 60 * 60 * 1000,
|
||||
updatedAt: Date.now() - 6 * 60 * 60 * 1000,
|
||||
publishedAt: Date.now() - 6 * 60 * 60 * 1000,
|
||||
status: 'published',
|
||||
revision: 3,
|
||||
},
|
||||
{
|
||||
id: '34c0ec54-bfb1-4ad7-81da-000000000005',
|
||||
title: 'Downtown retailers report strong evening trade',
|
||||
body: 'Independent retailers across downtown Los Santos reported stronger evening trade following the launch of extended opening hours. Business owners credited increased foot traffic and a busy restaurant district. The trial will continue through the end of the month before a permanent schedule is considered.',
|
||||
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,
|
||||
authorName: 'Maya Chen',
|
||||
createdAt: Date.now() - 26 * 60 * 60 * 1000,
|
||||
updatedAt: Date.now() - 25 * 60 * 60 * 1000,
|
||||
publishedAt: Date.now() - 25 * 60 * 60 * 1000,
|
||||
status: 'published',
|
||||
revision: 2,
|
||||
},
|
||||
{
|
||||
id: '34c0ec54-bfb1-4ad7-81da-000000000006',
|
||||
title: 'Interview: preparing for the next racing season',
|
||||
body: 'Local racing teams are preparing new vehicles and reviewing safety procedures before the next sanctioned season begins. Weazel News spoke with organizers about the revised technical checks, route planning, and what spectators can expect at the opening round.',
|
||||
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,
|
||||
authorName: 'Jordan Hayes',
|
||||
createdAt: Date.now() - 55 * 60 * 1000,
|
||||
updatedAt: Date.now() - 12 * 60 * 1000,
|
||||
publishedAt: null,
|
||||
status: 'draft',
|
||||
revision: 4,
|
||||
},
|
||||
{
|
||||
id: '34c0ec54-bfb1-4ad7-81da-000000000007',
|
||||
title: 'Draft briefing for Monday morning',
|
||||
body: 'The editorial desk is collecting confirmed service notices and transport updates for Monday morning. This working draft will be expanded when the final statements arrive from the relevant city departments.',
|
||||
excerpt:
|
||||
'The editorial desk is collecting confirmed service notices and transport updates for Monday morning.',
|
||||
category: 'official',
|
||||
imageUrl: null,
|
||||
imageMediaId: null,
|
||||
authorName: 'Jordan Hayes',
|
||||
createdAt: Date.now() - 18 * 60 * 1000,
|
||||
updatedAt: Date.now() - 8 * 60 * 1000,
|
||||
publishedAt: null,
|
||||
status: 'draft',
|
||||
revision: 2,
|
||||
},
|
||||
]
|
||||
|
||||
function weazelNewsExcerpt(body) {
|
||||
const normalized = body.replace(/\s+/g, ' ').trim()
|
||||
return normalized.length <= 240
|
||||
? normalized
|
||||
: `${normalized.slice(0, 237).trimEnd()}...`
|
||||
}
|
||||
|
||||
function weazelNewsImageUrl(imageMediaId) {
|
||||
if (imageMediaId === null) return null
|
||||
const media = mockMedia.find(
|
||||
(item) => item.id === imageMediaId && item.mediaType === 'photo',
|
||||
)
|
||||
return media?.url ?? null
|
||||
}
|
||||
|
||||
function validateWeazelNewsDraft(data) {
|
||||
const title = typeof data.title === 'string' ? data.title.trim() : ''
|
||||
const body = typeof data.body === 'string' ? data.body.trim() : ''
|
||||
const status = data.status
|
||||
const minimumTitleLength = 1
|
||||
const minimumBodyLength = 1
|
||||
if (
|
||||
Array.from(title).length < minimumTitleLength ||
|
||||
Array.from(title).length > 160 ||
|
||||
Array.from(body).length < minimumBodyLength ||
|
||||
Array.from(body).length > 12000 ||
|
||||
!weazelNewsCategoryIds.includes(data.category) ||
|
||||
!['draft', 'published'].includes(status)
|
||||
) {
|
||||
return { error: status === 'draft' ? 'invalid_draft' : 'invalid_publish' }
|
||||
}
|
||||
|
||||
let imageMediaId = null
|
||||
if (data.imageMediaId !== null && data.imageMediaId !== undefined) {
|
||||
imageMediaId = Number(data.imageMediaId)
|
||||
if (
|
||||
!Number.isSafeInteger(imageMediaId) ||
|
||||
!weazelNewsImageUrl(imageMediaId)
|
||||
) {
|
||||
return { error: 'invalid_attachment' }
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
article: {
|
||||
body,
|
||||
category: data.category,
|
||||
excerpt: weazelNewsExcerpt(body),
|
||||
imageMediaId,
|
||||
imageUrl: weazelNewsImageUrl(imageMediaId),
|
||||
status,
|
||||
title,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function pageWeazelNewsArticles(items, data) {
|
||||
const offset = Math.max(0, Math.floor(Number(data.offset) || 0))
|
||||
const requestedLimit = Math.floor(Number(data.limit) || 20)
|
||||
const limit = Math.min(50, Math.max(1, requestedLimit))
|
||||
return {
|
||||
hasMore: offset + limit < items.length,
|
||||
items: items.slice(offset, offset + limit).map((article) => {
|
||||
const summary = { ...article }
|
||||
delete summary.body
|
||||
return summary
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const marketplaceInquiries = [
|
||||
{
|
||||
id: '4903b923-409a-437e-971f-b7a2b10e9e31',
|
||||
@@ -4111,6 +4299,177 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
response.json({ success: true })
|
||||
return
|
||||
}
|
||||
const canManageWeazelNews = testScenario !== 'weazel-readonly'
|
||||
if (endpoint === 'weazel-news:context') {
|
||||
response.json({
|
||||
success: true,
|
||||
data: {
|
||||
canManage: canManageWeazelNews,
|
||||
categories: weazelNewsCategoryIds.map((id) => ({
|
||||
count: weazelNewsArticles.filter(
|
||||
(article) =>
|
||||
article.status === 'published' && article.category === id,
|
||||
).length,
|
||||
id,
|
||||
})),
|
||||
...(canManageWeazelNews
|
||||
? { jobGradeLabel: 'Senior Reporter', jobLabel: 'Weazel News' }
|
||||
: {}),
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
if (endpoint === 'weazel-news:list') {
|
||||
const category = request.body.category ?? null
|
||||
const search = String(request.body.search ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (
|
||||
category !== null &&
|
||||
!weazelNewsCategoryIds.includes(String(category))
|
||||
) {
|
||||
response.json({ success: false, error: 'invalid_request' })
|
||||
return
|
||||
}
|
||||
const items = weazelNewsArticles
|
||||
.filter((article) => article.status === 'published')
|
||||
.filter((article) => category === null || article.category === category)
|
||||
.filter(
|
||||
(article) =>
|
||||
!search ||
|
||||
`${article.title} ${article.body}`.toLowerCase().includes(search),
|
||||
)
|
||||
.sort((left, right) => (right.publishedAt ?? 0) - (left.publishedAt ?? 0))
|
||||
response.json({
|
||||
success: true,
|
||||
data: pageWeazelNewsArticles(items, request.body),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (endpoint === 'weazel-news:get') {
|
||||
if (request.body.manage === true && !canManageWeazelNews) {
|
||||
response.json({ success: false, error: 'not_authorized' })
|
||||
return
|
||||
}
|
||||
const article = weazelNewsArticles.find(
|
||||
(item) =>
|
||||
item.id === request.body.id &&
|
||||
(request.body.manage === true || item.status === 'published'),
|
||||
)
|
||||
response.json(
|
||||
article
|
||||
? { success: true, data: { article } }
|
||||
: { success: false, error: 'not_found' },
|
||||
)
|
||||
return
|
||||
}
|
||||
if (endpoint === 'weazel-news:manage-list') {
|
||||
if (!canManageWeazelNews) {
|
||||
response.json({ success: false, error: 'not_authorized' })
|
||||
return
|
||||
}
|
||||
const status = String(request.body.status ?? 'all')
|
||||
const search = String(request.body.search ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (!['all', 'published', 'draft'].includes(status)) {
|
||||
response.json({ success: false, error: 'invalid_request' })
|
||||
return
|
||||
}
|
||||
const items = weazelNewsArticles
|
||||
.filter((article) => status === 'all' || article.status === status)
|
||||
.filter(
|
||||
(article) =>
|
||||
!search ||
|
||||
`${article.title} ${article.body}`.toLowerCase().includes(search),
|
||||
)
|
||||
.sort((left, right) => right.updatedAt - left.updatedAt)
|
||||
response.json({
|
||||
success: true,
|
||||
data: pageWeazelNewsArticles(items, request.body),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (
|
||||
['weazel-news:create', 'weazel-news:update', 'weazel-news:delete'].includes(
|
||||
endpoint,
|
||||
) &&
|
||||
!canManageWeazelNews
|
||||
) {
|
||||
response.json({ success: false, error: 'not_authorized' })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'weazel-news:create') {
|
||||
const validation = validateWeazelNewsDraft(request.body)
|
||||
if (!validation.article) {
|
||||
response.json({ success: false, error: validation.error })
|
||||
return
|
||||
}
|
||||
const now = Date.now()
|
||||
const id = `34c0ec54-bfb1-4ad7-81da-${String(weazelNewsSequence).padStart(12, '0')}`
|
||||
weazelNewsSequence += 1
|
||||
const article = {
|
||||
...validation.article,
|
||||
authorName: 'Jordan Hayes',
|
||||
createdAt: now,
|
||||
id,
|
||||
publishedAt: validation.article.status === 'published' ? now : null,
|
||||
revision: 1,
|
||||
updatedAt: now,
|
||||
}
|
||||
weazelNewsArticles.unshift(article)
|
||||
response.json({ success: true, data: { article } })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'weazel-news:update') {
|
||||
const index = weazelNewsArticles.findIndex(
|
||||
(article) => article.id === request.body.id,
|
||||
)
|
||||
if (index < 0) {
|
||||
response.json({ success: false, error: 'not_found' })
|
||||
return
|
||||
}
|
||||
const current = weazelNewsArticles[index]
|
||||
if (current.revision !== Number(request.body.revision)) {
|
||||
response.json({ success: false, error: 'revision_conflict' })
|
||||
return
|
||||
}
|
||||
const validation = validateWeazelNewsDraft(request.body)
|
||||
if (!validation.article) {
|
||||
response.json({ success: false, error: validation.error })
|
||||
return
|
||||
}
|
||||
const now = Date.now()
|
||||
const article = {
|
||||
...current,
|
||||
...validation.article,
|
||||
publishedAt:
|
||||
validation.article.status === 'published'
|
||||
? (current.publishedAt ?? now)
|
||||
: null,
|
||||
revision: current.revision + 1,
|
||||
updatedAt: now,
|
||||
}
|
||||
weazelNewsArticles[index] = article
|
||||
response.json({ success: true, data: { article } })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'weazel-news:delete') {
|
||||
const index = weazelNewsArticles.findIndex(
|
||||
(article) => article.id === request.body.id,
|
||||
)
|
||||
if (index < 0) {
|
||||
response.json({ success: false, error: 'not_found' })
|
||||
return
|
||||
}
|
||||
if (weazelNewsArticles[index].revision !== Number(request.body.revision)) {
|
||||
response.json({ success: false, error: 'revision_conflict' })
|
||||
return
|
||||
}
|
||||
weazelNewsArticles.splice(index, 1)
|
||||
response.json({ success: true })
|
||||
return
|
||||
}
|
||||
if (endpoint.startsWith('companies:') && testScenario === 'companies-error') {
|
||||
response.json({ success: false, error: 'service_unavailable' })
|
||||
return
|
||||
|
||||
@@ -138,6 +138,131 @@ Locales["en"] = {
|
||||
unavailableBody = "This custom app could not be loaded. Check that its resource is started, then reopen the app.",
|
||||
close = "Close app",
|
||||
},
|
||||
weazelNews = {
|
||||
name = "Weazel News",
|
||||
brand = "WEAZEL NEWS",
|
||||
navigation = "Weazel News navigation",
|
||||
tabs = {
|
||||
home = "Home",
|
||||
categories = "Categories",
|
||||
search = "Search",
|
||||
editorial = "Editorial",
|
||||
},
|
||||
back = "Back",
|
||||
retry = "Try Again",
|
||||
loadMore = "Load More",
|
||||
home = {
|
||||
eyebrow = "Los Santos, live",
|
||||
latest = "Latest News",
|
||||
},
|
||||
categories = {
|
||||
title = "Categories",
|
||||
subtitle = "Browse every story from the Weazel News desk.",
|
||||
all = "All Stories",
|
||||
official = "Official Notices",
|
||||
events = "Events & Leisure",
|
||||
jobs = "Jobs",
|
||||
news = "News & Events",
|
||||
business = "Business",
|
||||
},
|
||||
search = {
|
||||
placeholder = "Search articles...",
|
||||
title = "Search Weazel News",
|
||||
emptyTitle = "No Articles Found",
|
||||
emptyBody = "Try another headline, topic, or category.",
|
||||
},
|
||||
states = {
|
||||
loading = "Loading Weazel News...",
|
||||
emptyTitle = "No News Yet",
|
||||
emptyBody = "The Weazel News desk has not published any articles here yet.",
|
||||
errorTitle = "Weazel News Is Unavailable",
|
||||
readOnlyTitle = "Read-only Access",
|
||||
readOnlyBody = "Your current job can read Weazel News, but cannot manage articles.",
|
||||
noManagedTitle = "No Editorial Articles",
|
||||
noManagedBody = "Create an article or change the current editorial filter.",
|
||||
},
|
||||
article = {
|
||||
readMore = "Read Article",
|
||||
byline = "By {author}",
|
||||
updated = "Updated {date}",
|
||||
published = "Published {date}",
|
||||
coverAlt = "Cover image for {title}",
|
||||
},
|
||||
editorial = {
|
||||
title = "Editorial Desk",
|
||||
subtitle = "Create, publish, and maintain Weazel News articles.",
|
||||
newArticle = "New Article",
|
||||
published = "Published",
|
||||
drafts = "Drafts",
|
||||
all = "All",
|
||||
jobAccess = "Signed in as {job}",
|
||||
edit = "Edit Article",
|
||||
delete = "Delete Article",
|
||||
},
|
||||
composer = {
|
||||
newTitle = "New Article",
|
||||
editTitle = "Edit Article",
|
||||
title = "Headline",
|
||||
titlePlaceholder = "Write a clear headline",
|
||||
body = "Article",
|
||||
bodyPlaceholder = "Write the full story...",
|
||||
category = "Category",
|
||||
status = "Publication Status",
|
||||
cover = "Cover Image",
|
||||
chooseCover = "Choose from Gallery",
|
||||
changeCover = "Change Cover",
|
||||
removeCover = "Remove Cover",
|
||||
coverAlt = "Selected article cover",
|
||||
publish = "Publish Article",
|
||||
saveDraft = "Save Draft",
|
||||
saveChanges = "Save Changes",
|
||||
statusPublished = "Published",
|
||||
statusDraft = "Draft",
|
||||
},
|
||||
delete = {
|
||||
title = "Delete Article?",
|
||||
body = "This article will be removed from Weazel News.",
|
||||
cancel = "Cancel",
|
||||
confirm = "Delete",
|
||||
},
|
||||
feedback = {
|
||||
created = "Article created.",
|
||||
updated = "Article updated.",
|
||||
deleted = "Article deleted.",
|
||||
},
|
||||
accessibility = {
|
||||
articleList = "Weazel News articles",
|
||||
categoryList = "News categories",
|
||||
editorialList = "Editorial articles",
|
||||
openArticle = "Open {title}",
|
||||
openCategory = "Open {category}",
|
||||
openEditorial = "Open the editorial desk",
|
||||
newArticle = "Create a new article",
|
||||
editArticle = "Edit {title}",
|
||||
deleteArticle = "Delete {title}",
|
||||
search = "Search Weazel News",
|
||||
clearSearch = "Clear article search",
|
||||
coverPreview = "Article cover preview",
|
||||
removeCover = "Remove the selected cover image",
|
||||
status = "Article status: {status}",
|
||||
},
|
||||
errors = {
|
||||
feature_disabled = "Weazel News is currently disabled.",
|
||||
invalid_article = "Enter a valid headline, article, and category.",
|
||||
invalid_draft = "Enter a headline and article text before saving the draft.",
|
||||
invalid_publish = "Enter a headline and article text before publishing.",
|
||||
invalid_request = "Check the article details and try again.",
|
||||
invalid_image = "Choose a valid photo from this phone.",
|
||||
invalid_attachment = "Choose a valid photo from this phone.",
|
||||
not_authorized = "Your current job cannot manage Weazel News articles.",
|
||||
article_not_found = "This article is no longer available.",
|
||||
not_found = "This article is no longer available.",
|
||||
revision_conflict = "This article changed on another device. Reload it and try again.",
|
||||
rate_limited = "Too many requests. Try again shortly.",
|
||||
request_failed = "Weazel News could not complete the request.",
|
||||
default = "Weazel News is temporarily unavailable.",
|
||||
},
|
||||
},
|
||||
crewlink = {
|
||||
name = "CrewLink", connecting = "Connecting your crew...", privateNetwork = "Private location network",
|
||||
signInTitle = "Connect your iFruit Account", signInBody = "CrewLink uses your private iFruit identity to keep groups and roles available across your phones.", openSettings = "Open iFruit Settings",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
Config.WeazelNews = {
|
||||
Enabled = true,
|
||||
PageSize = 20,
|
||||
MaximumOffset = 10000,
|
||||
SearchMaxLength = 80,
|
||||
DraftTitleMinLength = 1,
|
||||
DraftBodyMinLength = 1,
|
||||
TitleMinLength = 1,
|
||||
TitleMaxLength = 160,
|
||||
BodyMinLength = 1,
|
||||
BodyMaxLength = 12000,
|
||||
ExcerptMaxLength = 240,
|
||||
RateLimits = {
|
||||
Read = 120,
|
||||
Write = 20,
|
||||
},
|
||||
Categories = {
|
||||
"official",
|
||||
"events",
|
||||
"jobs",
|
||||
"news",
|
||||
"business",
|
||||
},
|
||||
-- Framework job name = minimum grade. Every listed job can manage every article.
|
||||
AllowedJobs = {
|
||||
weazel = 0,
|
||||
},
|
||||
}
|
||||
@@ -51,6 +51,7 @@ server_scripts {
|
||||
'config/companies.lua',
|
||||
'config/media.lua',
|
||||
'config/music.lua',
|
||||
'config/weazel_news.lua',
|
||||
'config/locales/*.lua',
|
||||
'source/bridge/server/database.lua',
|
||||
'source/bridge/server/migrations.lua',
|
||||
@@ -71,6 +72,7 @@ server_scripts {
|
||||
'source/server/payphones.lua',
|
||||
'source/server/calls.lua',
|
||||
'source/server/media.lua',
|
||||
'source/server/weazel_news.lua',
|
||||
'source/server/messages.lua',
|
||||
'source/server/easyshare.lua',
|
||||
'source/server/darkchat.lua',
|
||||
|
||||
@@ -76,6 +76,13 @@ local server_callbacks = {
|
||||
"pages:share-citymarkt",
|
||||
"pages:react",
|
||||
"pages:delete",
|
||||
"weazel-news:context",
|
||||
"weazel-news:list",
|
||||
"weazel-news:get",
|
||||
"weazel-news:manage-list",
|
||||
"weazel-news:create",
|
||||
"weazel-news:update",
|
||||
"weazel-news:delete",
|
||||
"fliptok:register",
|
||||
"fliptok:login",
|
||||
"fliptok:logout",
|
||||
|
||||
@@ -2499,6 +2499,38 @@ local schema = {
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_weazel_articles",
|
||||
columns = {
|
||||
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "title", type = "VARCHAR(160) NOT NULL" },
|
||||
{ name = "body", type = "LONGTEXT NOT NULL" },
|
||||
{ name = "excerpt", type = "VARCHAR(240) NOT NULL" },
|
||||
{ name = "category", type = "ENUM('official','events','jobs','news','business') NOT NULL" },
|
||||
{ name = "image_media_id", type = "BIGINT UNSIGNED NULL" },
|
||||
{ name = "author_identifier", type = "VARCHAR(80) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "author_name", type = "VARCHAR(120) NOT NULL" },
|
||||
{ name = "updated_by_identifier", type = "VARCHAR(80) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "status", type = "ENUM('draft','published') NOT NULL DEFAULT 'draft'" },
|
||||
{ name = "revision", type = "INT UNSIGNED NOT NULL DEFAULT 1" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
{ name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" },
|
||||
{ name = "published_at", type = "DATETIME NULL" },
|
||||
{ name = "deleted_at", type = "DATETIME NULL" },
|
||||
{ name = "deleted_by_identifier", type = "VARCHAR(80) NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
indexes = {
|
||||
{ name = "idx_sky_phone_weazel_public", columns = "(`status`, `deleted_at`, `published_at`, `id`)" },
|
||||
{ name = "idx_sky_phone_weazel_category", columns = "(`category`, `status`, `deleted_at`, `published_at`, `id`)" },
|
||||
{ name = "idx_sky_phone_weazel_manage", columns = "(`deleted_at`, `status`, `updated_at`, `id`)" },
|
||||
{ name = "idx_sky_phone_weazel_media", columns = "(`image_media_id`)" },
|
||||
},
|
||||
foreignKeys = {
|
||||
{ column = "image_media_id", references = "`sky_phone_media` (`id`) ON DELETE SET NULL" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
}
|
||||
|
||||
Bridge.Database.Migrate("sky_phone", schema)
|
||||
|
||||
@@ -0,0 +1,597 @@
|
||||
Bridge.Database.AfterMigration("sky_phone", function()
|
||||
local config = Config.WeazelNews
|
||||
|
||||
if type(config) ~= "table" then
|
||||
error("[sky_phone] Config.WeazelNews must be configured.")
|
||||
end
|
||||
|
||||
local function require_integer_config(name, minimum, maximum)
|
||||
local value = config[name]
|
||||
if type(value) ~= "number" or value ~= math.floor(value) or value < minimum or value > maximum then
|
||||
error(("[sky_phone] Config.WeazelNews.%s must be an integer between %d and %d."):format(name, minimum, maximum))
|
||||
end
|
||||
end
|
||||
|
||||
if type(config.Enabled) ~= "boolean" then
|
||||
error("[sky_phone] Config.WeazelNews.Enabled must be true or false.")
|
||||
end
|
||||
require_integer_config("PageSize", 1, 100)
|
||||
require_integer_config("MaximumOffset", 0, 1000000)
|
||||
require_integer_config("SearchMaxLength", 1, 256)
|
||||
require_integer_config("DraftTitleMinLength", 1, 160)
|
||||
require_integer_config("DraftBodyMinLength", 1, 12000)
|
||||
require_integer_config("TitleMinLength", 1, 160)
|
||||
require_integer_config("TitleMaxLength", 1, 160)
|
||||
require_integer_config("BodyMinLength", 1, 12000)
|
||||
require_integer_config("BodyMaxLength", 1, 12000)
|
||||
require_integer_config("ExcerptMaxLength", 1, 240)
|
||||
if config.DraftTitleMinLength > config.TitleMaxLength
|
||||
or config.DraftBodyMinLength > config.BodyMaxLength
|
||||
or config.TitleMinLength > config.TitleMaxLength
|
||||
or config.BodyMinLength > config.BodyMaxLength
|
||||
then
|
||||
error("[sky_phone] Weazel News minimum text lengths cannot exceed their maximums.")
|
||||
end
|
||||
if type(config.RateLimits) ~= "table" then
|
||||
error("[sky_phone] Config.WeazelNews.RateLimits must be a table.")
|
||||
end
|
||||
for _, name in ipairs({ "Read", "Write" }) do
|
||||
local value = config.RateLimits[name]
|
||||
if type(value) ~= "number" or value ~= math.floor(value) or value < 1 or value > 10000 then
|
||||
error(("[sky_phone] Config.WeazelNews.RateLimits.%s must be an integer between 1 and 10000."):format(name))
|
||||
end
|
||||
end
|
||||
|
||||
local supported_categories = {
|
||||
official = true,
|
||||
events = true,
|
||||
jobs = true,
|
||||
news = true,
|
||||
business = true,
|
||||
}
|
||||
if type(config.Categories) ~= "table" then
|
||||
error("[sky_phone] Config.WeazelNews.Categories must be a table.")
|
||||
end
|
||||
|
||||
local categories = {}
|
||||
for _, category in ipairs(config.Categories or {}) do
|
||||
if type(category) ~= "string" or #category < 1 or #category > 32
|
||||
or not category:match("^[%l_]+$") or categories[category]
|
||||
then
|
||||
error(("[sky_phone] Invalid Weazel News category '%s'."):format(tostring(category)))
|
||||
end
|
||||
categories[category] = true
|
||||
end
|
||||
for category in pairs(supported_categories) do
|
||||
if not categories[category] then
|
||||
error(("[sky_phone] Config.WeazelNews.Categories is missing supported category '%s'."):format(category))
|
||||
end
|
||||
end
|
||||
for category in pairs(categories) do
|
||||
if not supported_categories[category] then
|
||||
error(("[sky_phone] Config.WeazelNews.Categories contains unsupported category '%s'."):format(category))
|
||||
end
|
||||
end
|
||||
|
||||
if type(config.AllowedJobs) ~= "table" then
|
||||
error("[sky_phone] Config.WeazelNews.AllowedJobs must be a table.")
|
||||
end
|
||||
|
||||
for job_name, minimum_grade in pairs(config.AllowedJobs or {}) do
|
||||
if type(job_name) ~= "string" or #job_name < 1 or #job_name > 64
|
||||
or not job_name:match("^[%w_-]+$")
|
||||
or type(minimum_grade) ~= "number" or minimum_grade < 0
|
||||
or minimum_grade ~= math.floor(minimum_grade)
|
||||
then
|
||||
error(("[sky_phone] Invalid Weazel News AllowedJobs entry '%s'."):format(tostring(job_name)))
|
||||
end
|
||||
end
|
||||
|
||||
local article_summary_columns = [[
|
||||
article.`id`, article.`title`, article.`excerpt`, article.`category`,
|
||||
article.`image_media_id`, media.`url` AS `image_url`, article.`author_name`,
|
||||
article.`status`, article.`revision`,
|
||||
UNIX_TIMESTAMP(article.`created_at`) AS `created_at_unix`,
|
||||
UNIX_TIMESTAMP(article.`updated_at`) AS `updated_at_unix`,
|
||||
UNIX_TIMESTAMP(article.`published_at`) AS `published_at_unix`
|
||||
]]
|
||||
local article_detail_columns = article_summary_columns .. ", article.`body`"
|
||||
|
||||
local function trim(value)
|
||||
if type(value) ~= "string" then
|
||||
return nil
|
||||
end
|
||||
return value:match("^%s*(.-)%s*$")
|
||||
end
|
||||
|
||||
local function text_length(value)
|
||||
if type(value) ~= "string" then
|
||||
return nil
|
||||
end
|
||||
local success, length = pcall(utf8.len, value)
|
||||
return success and length or nil
|
||||
end
|
||||
|
||||
local function truncate_text(value, maximum)
|
||||
local length = text_length(value)
|
||||
if not length or length <= maximum then
|
||||
return value
|
||||
end
|
||||
local next_character = utf8.offset(value, maximum + 1)
|
||||
return next_character and value:sub(1, next_character - 1) or value
|
||||
end
|
||||
|
||||
local function make_excerpt(body)
|
||||
return truncate_text(body:gsub("%s+", " "), config.ExcerptMaxLength)
|
||||
end
|
||||
|
||||
local function valid_integer(value, minimum, maximum)
|
||||
local number = type(value) == "number" and value or nil
|
||||
if not number or number ~= math.floor(number) or number < minimum or number > maximum then
|
||||
return nil
|
||||
end
|
||||
return number
|
||||
end
|
||||
|
||||
local function valid_uuid(value)
|
||||
return type(value) == "string"
|
||||
and value:match("^%x%x%x%x%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%x%x%x%x%x%x%x%x$") ~= nil
|
||||
end
|
||||
|
||||
local function affected_rows(result)
|
||||
if type(result) == "number" then
|
||||
return result
|
||||
end
|
||||
return type(result) == "table" and tonumber(result.affectedRows) or 0
|
||||
end
|
||||
|
||||
local function require_phone(source, operation, maximum)
|
||||
if not config.Enabled then
|
||||
return nil, { success = false, error = "feature_disabled" }
|
||||
end
|
||||
local session, error_response = SkyPhone.RequireSession(source)
|
||||
if not session then
|
||||
return nil, error_response
|
||||
end
|
||||
if not SkyPhone.AllowOperation(source, "weazel_" .. operation, maximum, 60) then
|
||||
return nil, { success = false, error = "rate_limited" }
|
||||
end
|
||||
return session
|
||||
end
|
||||
|
||||
local function management_access(source)
|
||||
local job = Bridge.Framework.GetJob(source)
|
||||
local minimum_grade = (config.AllowedJobs or {})[job.name]
|
||||
local grade = tonumber(job.grade) or 0
|
||||
if minimum_grade == nil or grade < minimum_grade then
|
||||
return nil
|
||||
end
|
||||
return {
|
||||
job_label = type(job.label) == "string" and job.label or "",
|
||||
grade_label = type(job.gradeLabel) == "string" and job.gradeLabel or "",
|
||||
}
|
||||
end
|
||||
|
||||
local function require_manager(source)
|
||||
local access = management_access(source)
|
||||
if not access then
|
||||
return nil, { success = false, error = "not_authorized" }
|
||||
end
|
||||
return access
|
||||
end
|
||||
|
||||
local function actor_identity(source)
|
||||
local identifier = Bridge.Framework.GetIdentifier(source)
|
||||
if type(identifier) ~= "string" or #identifier < 1 or #identifier > 80 then
|
||||
Bridge.Debug("error", "[sky_phone] Could not resolve a valid Weazel News actor for source %s.", tostring(source))
|
||||
return nil
|
||||
end
|
||||
|
||||
local first_name = trim(Bridge.Framework.GetFirstname(source)) or ""
|
||||
local last_name = trim(Bridge.Framework.GetLastname(source)) or ""
|
||||
local name = trim((first_name .. " " .. last_name))
|
||||
if not name or name == "" then
|
||||
name = trim(GetPlayerName(source)) or "Weazel News"
|
||||
end
|
||||
if not text_length(name) then
|
||||
name = "Weazel News"
|
||||
end
|
||||
return {
|
||||
identifier = identifier,
|
||||
name = truncate_text(name, 120),
|
||||
}
|
||||
end
|
||||
|
||||
local function article_dto(row)
|
||||
if not row then
|
||||
return nil
|
||||
end
|
||||
local created_at = tonumber(row.created_at_unix)
|
||||
local updated_at = tonumber(row.updated_at_unix)
|
||||
if not created_at or not updated_at then
|
||||
error(("[sky_phone] Weazel News article '%s' has invalid timestamps."):format(tostring(row.id)))
|
||||
end
|
||||
return {
|
||||
id = row.id,
|
||||
title = row.title,
|
||||
body = row.body,
|
||||
excerpt = row.excerpt,
|
||||
category = row.category,
|
||||
imageUrl = row.image_url,
|
||||
imageMediaId = row.image_media_id and tonumber(row.image_media_id) or nil,
|
||||
authorName = row.author_name,
|
||||
createdAt = created_at * 1000,
|
||||
updatedAt = updated_at * 1000,
|
||||
publishedAt = row.published_at_unix and tonumber(row.published_at_unix) * 1000 or nil,
|
||||
status = row.status,
|
||||
revision = tonumber(row.revision) or 1,
|
||||
}
|
||||
end
|
||||
|
||||
local function load_article(id, include_drafts)
|
||||
local visibility = include_drafts and "" or " AND article.`status` = 'published'"
|
||||
local rows = Bridge.Database.Query(([[
|
||||
SELECT %s
|
||||
FROM `sky_phone_weazel_articles` article
|
||||
LEFT JOIN `sky_phone_media` media ON media.`id` = article.`image_media_id`
|
||||
WHERE article.`id` = ? AND article.`deleted_at` IS NULL%s
|
||||
LIMIT 1
|
||||
]]):format(article_detail_columns, visibility), { id })
|
||||
return article_dto(rows[1])
|
||||
end
|
||||
|
||||
local function query_articles(where_clause, parameters, order_clause, offset)
|
||||
local query_parameters = {}
|
||||
for _, value in ipairs(parameters) do
|
||||
query_parameters[#query_parameters + 1] = value
|
||||
end
|
||||
query_parameters[#query_parameters + 1] = config.PageSize + 1
|
||||
query_parameters[#query_parameters + 1] = offset
|
||||
|
||||
local rows = Bridge.Database.Query(([[
|
||||
SELECT %s
|
||||
FROM `sky_phone_weazel_articles` article
|
||||
LEFT JOIN `sky_phone_media` media ON media.`id` = article.`image_media_id`
|
||||
WHERE %s
|
||||
ORDER BY %s
|
||||
LIMIT ? OFFSET ?
|
||||
]]):format(article_summary_columns, where_clause, order_clause), query_parameters)
|
||||
local has_more = #rows > config.PageSize
|
||||
if has_more then
|
||||
rows[#rows] = nil
|
||||
end
|
||||
local articles = {}
|
||||
for _, row in ipairs(rows) do
|
||||
articles[#articles + 1] = article_dto(row)
|
||||
end
|
||||
return articles, has_more
|
||||
end
|
||||
|
||||
local function validate_article(source, data, retained_media_id)
|
||||
if type(data) ~= "table" then
|
||||
return nil, "invalid_article"
|
||||
end
|
||||
local title = trim(data.title)
|
||||
local body = trim(data.body)
|
||||
local title_length = text_length(title)
|
||||
local body_length = text_length(body)
|
||||
local status = data.status
|
||||
local minimum_title_length = status == "draft" and config.DraftTitleMinLength or config.TitleMinLength
|
||||
local minimum_body_length = status == "draft" and config.DraftBodyMinLength or config.BodyMinLength
|
||||
if not title_length or title:find("%z") or title_length < minimum_title_length or title_length > config.TitleMaxLength
|
||||
or not body_length or body:find("%z")
|
||||
or body_length < minimum_body_length or body_length > config.BodyMaxLength
|
||||
or not categories[data.category]
|
||||
or (status ~= "draft" and status ~= "published")
|
||||
then
|
||||
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
|
||||
end
|
||||
return {
|
||||
title = title,
|
||||
body = body,
|
||||
excerpt = make_excerpt(body),
|
||||
category = data.category,
|
||||
image_media_id = media_id,
|
||||
status = status,
|
||||
}
|
||||
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
|
||||
return error_response
|
||||
end
|
||||
local access = management_access(source)
|
||||
local counts = Bridge.Database.Query([[
|
||||
SELECT `category`, COUNT(*) AS `count`
|
||||
FROM `sky_phone_weazel_articles`
|
||||
WHERE `status` = 'published' AND `deleted_at` IS NULL
|
||||
GROUP BY `category`
|
||||
]], {})
|
||||
local counts_by_category = {}
|
||||
for _, row in ipairs(counts) do
|
||||
counts_by_category[row.category] = tonumber(row.count) or 0
|
||||
end
|
||||
local category_context = {}
|
||||
for _, category in ipairs(config.Categories) do
|
||||
category_context[#category_context + 1] = {
|
||||
id = category,
|
||||
count = counts_by_category[category] or 0,
|
||||
}
|
||||
end
|
||||
return {
|
||||
success = true,
|
||||
data = {
|
||||
canManage = access ~= nil,
|
||||
jobLabel = access and access.job_label or nil,
|
||||
jobGradeLabel = access and access.grade_label or nil,
|
||||
categories = category_context,
|
||||
},
|
||||
}
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:weazel-news:list", function(source, data)
|
||||
local _, error_response = require_phone(source, "read", config.RateLimits.Read)
|
||||
if error_response then
|
||||
return error_response
|
||||
end
|
||||
if type(data) ~= "table" then
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
local offset = valid_integer(data.offset or 0, 0, config.MaximumOffset)
|
||||
local search = trim(data.search or "")
|
||||
local search_length = text_length(search)
|
||||
if not offset or not search_length or search_length > config.SearchMaxLength
|
||||
or (data.category ~= nil and not categories[data.category])
|
||||
then
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
|
||||
local where = { "article.`status` = 'published'", "article.`deleted_at` IS NULL" }
|
||||
local parameters = {}
|
||||
if data.category then
|
||||
where[#where + 1] = "article.`category` = ?"
|
||||
parameters[#parameters + 1] = data.category
|
||||
end
|
||||
if search ~= "" then
|
||||
where[#where + 1] = "(article.`title` LIKE CONCAT('%', ?, '%') OR article.`body` LIKE CONCAT('%', ?, '%'))"
|
||||
parameters[#parameters + 1] = search
|
||||
parameters[#parameters + 1] = search
|
||||
end
|
||||
local items, has_more = query_articles(
|
||||
table.concat(where, " AND "),
|
||||
parameters,
|
||||
"article.`published_at` DESC, article.`id` DESC",
|
||||
offset
|
||||
)
|
||||
return { success = true, data = { items = items, hasMore = has_more } }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:weazel-news:get", function(source, data)
|
||||
local _, error_response = require_phone(source, "read", config.RateLimits.Read)
|
||||
if error_response then
|
||||
return error_response
|
||||
end
|
||||
if type(data) ~= "table" or not valid_uuid(data.id) then
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
local include_drafts = data.manage == true
|
||||
if include_drafts then
|
||||
local _, manager_error = require_manager(source)
|
||||
if manager_error then
|
||||
return manager_error
|
||||
end
|
||||
end
|
||||
local article = load_article(data.id, include_drafts)
|
||||
if not article then
|
||||
return { success = false, error = "not_found" }
|
||||
end
|
||||
return { success = true, data = { article = article } }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:weazel-news:manage-list", function(source, data)
|
||||
local _, error_response = require_phone(source, "read", config.RateLimits.Read)
|
||||
if error_response then
|
||||
return error_response
|
||||
end
|
||||
local _, manager_error = require_manager(source)
|
||||
if manager_error then
|
||||
return manager_error
|
||||
end
|
||||
if type(data) ~= "table" then
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
local status = data.status or "all"
|
||||
local offset = valid_integer(data.offset or 0, 0, config.MaximumOffset)
|
||||
local search = trim(data.search or "")
|
||||
local search_length = text_length(search)
|
||||
if (status ~= "all" and status ~= "published" and status ~= "draft") or not offset
|
||||
or not search_length or search_length > config.SearchMaxLength
|
||||
then
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
local where = { "article.`deleted_at` IS NULL" }
|
||||
local parameters = {}
|
||||
if status ~= "all" then
|
||||
where[#where + 1] = "article.`status` = ?"
|
||||
parameters[#parameters + 1] = status
|
||||
end
|
||||
if search ~= "" then
|
||||
where[#where + 1] = "(article.`title` LIKE CONCAT('%', ?, '%') OR article.`body` LIKE CONCAT('%', ?, '%'))"
|
||||
parameters[#parameters + 1] = search
|
||||
parameters[#parameters + 1] = search
|
||||
end
|
||||
local items, has_more = query_articles(
|
||||
table.concat(where, " AND "),
|
||||
parameters,
|
||||
"article.`updated_at` DESC, article.`id` DESC",
|
||||
offset
|
||||
)
|
||||
return { success = true, data = { items = items, hasMore = has_more } }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:weazel-news:create", function(source, data)
|
||||
local _, error_response = require_phone(source, "write", config.RateLimits.Write)
|
||||
if error_response then
|
||||
return error_response
|
||||
end
|
||||
local _, manager_error = require_manager(source)
|
||||
if manager_error then
|
||||
return manager_error
|
||||
end
|
||||
local article, validation_error = validate_article(source, data)
|
||||
if not article then
|
||||
return { success = false, error = validation_error }
|
||||
end
|
||||
local actor = actor_identity(source)
|
||||
if not actor then
|
||||
return { success = false, error = "request_failed" }
|
||||
end
|
||||
local ids = Bridge.Database.Query("SELECT UUID() AS `id`", {})
|
||||
local id = ids[1] and ids[1].id
|
||||
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 created = load_article(id, true)
|
||||
if not created then
|
||||
error(("[sky_phone] Could not reload created Weazel News article '%s'."):format(id))
|
||||
end
|
||||
return { success = true, data = { article = created } }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:weazel-news:update", function(source, data)
|
||||
local _, error_response = require_phone(source, "write", config.RateLimits.Write)
|
||||
if error_response then
|
||||
return error_response
|
||||
end
|
||||
local _, manager_error = require_manager(source)
|
||||
if manager_error then
|
||||
return manager_error
|
||||
end
|
||||
local revision = type(data) == "table" and valid_integer(data.revision, 1, 4294967295) or nil
|
||||
if not revision or not valid_uuid(data.id) then
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
local current_rows = Bridge.Database.Query([[
|
||||
SELECT `image_media_id`, `revision`
|
||||
FROM `sky_phone_weazel_articles`
|
||||
WHERE `id` = ? AND `deleted_at` IS NULL
|
||||
LIMIT 1
|
||||
]], { data.id })
|
||||
local current = current_rows[1]
|
||||
if not current then
|
||||
return { success = false, error = "not_found" }
|
||||
end
|
||||
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)
|
||||
if not article then
|
||||
return { success = false, error = validation_error }
|
||||
end
|
||||
local actor = actor_identity(source)
|
||||
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" }
|
||||
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))
|
||||
end
|
||||
return { success = true, data = { article = updated } }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:weazel-news:delete", function(source, data)
|
||||
local _, error_response = require_phone(source, "write", config.RateLimits.Write)
|
||||
if error_response then
|
||||
return error_response
|
||||
end
|
||||
local _, manager_error = require_manager(source)
|
||||
if manager_error then
|
||||
return manager_error
|
||||
end
|
||||
local revision = type(data) == "table" and valid_integer(data.revision, 1, 4294967295) or nil
|
||||
if not revision or not valid_uuid(data.id) then
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
local actor = actor_identity(source)
|
||||
if not actor then
|
||||
return { success = false, error = "request_failed" }
|
||||
end
|
||||
local result = Bridge.Database.Query([[
|
||||
UPDATE `sky_phone_weazel_articles`
|
||||
SET `deleted_at` = CURRENT_TIMESTAMP, `deleted_by_identifier` = ?, `revision` = `revision` + 1
|
||||
WHERE `id` = ? AND `revision` = ? AND `deleted_at` IS NULL
|
||||
]], { actor.identifier, data.id, revision })
|
||||
if affected_rows(result) ~= 1 then
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT `revision`, `deleted_at`
|
||||
FROM `sky_phone_weazel_articles`
|
||||
WHERE `id` = ?
|
||||
LIMIT 1
|
||||
]], { data.id })
|
||||
if not rows[1] or rows[1].deleted_at then
|
||||
return { success = false, error = "not_found" }
|
||||
end
|
||||
return { success = false, error = "revision_conflict" }
|
||||
end
|
||||
return { success = true }
|
||||
end)
|
||||
end)
|
||||
@@ -1138,3 +1138,28 @@ CREATE TABLE IF NOT EXISTS `sky_phone_company_audit` (
|
||||
KEY `idx_sky_phone_company_audit` (`company_id`,`created_at`,`id`),
|
||||
FOREIGN KEY (`company_id`) REFERENCES `sky_phone_company_profiles` (`company_id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_weazel_articles` (
|
||||
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`title` VARCHAR(160) NOT NULL,
|
||||
`body` LONGTEXT NOT NULL,
|
||||
`excerpt` VARCHAR(240) NOT NULL,
|
||||
`category` ENUM('official','events','jobs','news','business') NOT NULL,
|
||||
`image_media_id` BIGINT UNSIGNED NULL,
|
||||
`author_identifier` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`author_name` VARCHAR(120) NOT NULL,
|
||||
`updated_by_identifier` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`status` ENUM('draft','published') NOT NULL DEFAULT 'draft',
|
||||
`revision` INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`published_at` DATETIME NULL,
|
||||
`deleted_at` DATETIME NULL,
|
||||
`deleted_by_identifier` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_sky_phone_weazel_public` (`status`,`deleted_at`,`published_at`,`id`),
|
||||
KEY `idx_sky_phone_weazel_category` (`category`,`status`,`deleted_at`,`published_at`,`id`),
|
||||
KEY `idx_sky_phone_weazel_manage` (`deleted_at`,`status`,`updated_at`,`id`),
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user