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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions codegen.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
"IcymiTopic": "./misc.js#MattersChoiceTopic",
"Moment": "./moment.js#Moment",
"MomentFeedApplication": "./moment.js#MomentFeedUser",
"Quote": "./quote.js#Quote",
"Writing": "./index.js#Writing",
"Campaign": "./campaign.js#Campaign",
"CampaignOSS": "./campaign.js#Campaign",
Expand Down
34 changes: 34 additions & 0 deletions db/migrations/20260611100000_create_quote_table.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { baseDown } from '../utils.js'

const table = 'quote'

const indexName = `${table}_user_id_article_id_content_unique`

export const up = async (knex) => {
await knex('entity_type').insert({ table })
await knex.schema.createTable(table, (t) => {
t.bigIncrements('id').primary()
t.text('content').notNullable()
t.bigInteger('article_id').unsigned().notNullable()
t.bigInteger('campaign_id').unsigned().notNullable()
t.bigInteger('user_id').unsigned().notNullable()
t.enu('state', ['active', 'archived', 'banned']).notNullable()
t.timestamp('created_at').defaultTo(knex.fn.now())
t.timestamp('updated_at').defaultTo(knex.fn.now())

t.foreign('article_id').references('id').inTable('article')
t.foreign('campaign_id').references('id').inTable('campaign')
t.foreign('user_id').references('id').inTable('user')

t.index('campaign_id')
t.index('article_id')
t.index(['user_id', 'created_at'])
})

// create a partial unique index to dedupe active quotes
await knex.raw(
`CREATE UNIQUE INDEX ${indexName} ON ${table} ("user_id", "article_id", "content") WHERE state = 'active'`
)
}

export const down = baseDown(table)
60 changes: 60 additions & 0 deletions schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,14 @@ type Mutation {
unlikeMoment(input: UnlikeMomentInput!): Moment!
applyMomentFeed: User!
updateMomentFeedApplicationState(input: UpdateMomentFeedApplicationStateInput!): User!

"""Post a quote (selected from an article) onto the campaign quote wall."""
putQuote(input: PutQuoteInput!): Quote!

"""
Retract a quote from the wall (poster, source article author, or admin).
"""
deleteQuote(input: DeleteQuoteInput!): Boolean!
putTopicChannel(input: PutTopicChannelInput!): TopicChannel!
putCurationChannel(input: PutCurationChannelInput!): CurationChannel!
putTagChannel(input: PutTagChannelInput!): Tag!
Expand Down Expand Up @@ -1073,13 +1081,25 @@ type WritingChallenge implements Node & Campaign & Channel {
state: CampaignState!
participants(input: CampaignParticipantsInput!): CampaignParticipantConnection!
articles(input: CampaignArticlesInput!): CampaignArticleConnection!

"""Comments made by campaign participants (public to read)."""
discussion(input: CommentsInput!): CommentConnection!

"""Discussion (include replies) count of this campaign."""
discussionCount: Int!
application: CampaignApplication
featuredDescription(input: TranslationArgs): String!
organizers: [User!]!
isManager: Boolean!
showOther: Boolean!
showAd: Boolean!
oss: CampaignOSS!

"""Quotes on this campaign's quote wall (public)."""
quotes(input: QuotesInput!): QuoteConnection!

"""Quote count of this campaign's quote wall."""
quoteCount: Int!
}

type CampaignOSS {
Expand Down Expand Up @@ -1724,6 +1744,7 @@ input CommentInput {
articleId: ID
circleId: ID
momentId: ID
campaignId: ID
}

input CommentCommentsInput {
Expand Down Expand Up @@ -1914,6 +1935,7 @@ enum CommentType {
circleDiscussion
circleBroadcast
moment
campaignDiscussion
}

"""
Expand Down Expand Up @@ -4443,6 +4465,44 @@ enum MomentFeedUserReviewedBy {
seed
}

input PutQuoteInput {
articleId: ID!
content: String!
}

input DeleteQuoteInput {
id: ID!
}

input QuotesInput {
first: Int
after: String

"""random sampling for wall display; refetch to shuffle. when true, after is ignored"""
random: Boolean
}

type Quote {
id: ID!
content: String!
article: Article!

"""the user who posted this quote onto the wall"""
poster: User!
createdAt: DateTime!
}

type QuoteConnection implements Connection {
totalCount: Int!
pageInfo: PageInfo!
edges: [QuoteEdge!]
}

type QuoteEdge {
cursor: String!
node: Quote!
}

interface Channel {
id: ID!
shortHash: String!
Expand Down
6 changes: 6 additions & 0 deletions src/common/enums/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export * from './appreciation.js'
export * from './metrics.js'
export * from './badges.js'
export * from './moment.js'
export * from './quote.js'
export * from './campaign.js'
export * from './channel.js'
export * from './feedback.js'
Expand Down Expand Up @@ -60,6 +61,7 @@ export const COMMENT_TYPE = {
circleDiscussion: 'circle_discussion',
circleBroadcast: 'circle_broadcast',
moment: 'moment',
campaignDiscussion: 'campaign_discussion',
} as const

export const COMMENT_TYPES_REVERSED = Object.fromEntries(
Expand Down Expand Up @@ -168,6 +170,7 @@ export enum NODE_TYPES {
Collection = 'Collection',
Report = 'Report',
Moment = 'Moment',
Quote = 'Quote',
Campaign = 'Campaign',
CampaignStage = 'CampaignStage',
TopicChannel = 'TopicChannel',
Expand Down Expand Up @@ -251,6 +254,9 @@ export const MAX_ARTICLE_CONTENT_REVISION_LENGTH = 50

export const MAX_ARTICLE_COMMENT_LENGTH = 1200

// campaign discussion comment length cap (matches moment / 短動態 = 240)
export const MAX_CAMPAIGN_COMMENT_LENGTH = 240

export const MAX_PINNED_WORKS_LIMIT = 3

export const LATEST_WORKS_NUM = 4
Expand Down
12 changes: 12 additions & 0 deletions src/common/enums/quote.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export const QUOTE_STATE = {
active: 'active',
archived: 'archived',
banned: 'banned',
} as const

// a quote is a "one-glance" excerpt; longer text is a paragraph, not a quote
export const MAX_QUOTE_LENGTH = 80

// anti-abuse caps (product-decided defaults, tunable)
export const QUOTE_DAILY_LIMIT = 5
export const QUOTE_PER_ARTICLE_LIMIT = 2
1 change: 1 addition & 0 deletions src/connectors/atomService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -647,6 +647,7 @@ const UPATEABLE_TABLES = [
'collection',
'matters_choice_topic',
'moment',
'quote',
'moment_asset',
'moment_article',
'moment_tag',
Expand Down
6 changes: 6 additions & 0 deletions src/connectors/campaignService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,12 @@ export class CampaignService {
where: { userId, campaignId },
})

// whether the user has successfully applied to (i.e. participates in) the campaign
public isParticipant = async (campaignId: string, userId: string) => {
const application = await this.getApplication(campaignId, userId)
return application?.state === CAMPAIGN_USER_STATE.succeeded
}

public findAndCountAll = async (
{ skip, take }: { skip: number; take: number },
{
Expand Down
3 changes: 3 additions & 0 deletions src/definitions/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ import type {
PayoutAccount,
Transaction,
} from './payment.js'
import type { Quote } from './quote.js'
import type { Report } from './report.js'
import type { Tag, TagTranslation, UserTagsOrder } from './tag.js'
import type { Translation } from './translation.js'
Expand Down Expand Up @@ -172,6 +173,7 @@ export * from './wallet.js'
export * from './misc.js'
export * from './schema.js'
export * from './moment.js'
export * from './quote.js'
export * from './campaign.js'
export * from './translation.js'
export * from './channel.js'
Expand Down Expand Up @@ -287,6 +289,7 @@ export interface TableTypeMap {
featured_comment_materialized: FeaturedCommentMaterialized
feature_flag: FeatureFlag
moment: Moment
quote: Quote
moment_asset: MomentAsset
moment_article: MomentArticle
moment_tag: MomentTag
Expand Down
13 changes: 13 additions & 0 deletions src/definitions/quote.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { QUOTE_STATE } from '#common/enums/index.js'
import type { ValueOf } from './generic.js'

export interface Quote {
id: string
content: string
articleId: string
campaignId: string
userId: string
state: ValueOf<typeof QUOTE_STATE>
createdAt: Date
updatedAt: Date
}
Loading
Loading