ADD - implement Weazel News app

This commit is contained in:
Dominik
2026-08-12 23:58:36 +02:00
parent b9b044832d
commit ce6393ba07
19 changed files with 4255 additions and 0 deletions
+20
View File
@@ -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

+14
View File
@@ -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',
+16
View File
@@ -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(
+130
View File
@@ -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',
+212
View File
@@ -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()
})
})
+283
View File
@@ -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
},
},
})
+1
View File
@@ -37,6 +37,7 @@ export type BuiltinPhoneAppId =
| 'feather'
| 'crewlink'
| 'companies'
| 'weazel-news'
declare const externalPhoneAppId: unique symbol
+76
View File
@@ -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
}
+1
View File
@@ -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
+359
View File
@@ -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
+125
View File
@@ -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",
+28
View File
@@ -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,
},
}
+2
View File
@@ -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',
+7
View File
@@ -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",
+32
View File
@@ -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)
+597
View File
@@ -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)
+25
View File
@@ -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;