diff --git a/backend/api/src/app.ts b/backend/api/src/app.ts index 00080ce4..eb26619b 100644 --- a/backend/api/src/app.ts +++ b/backend/api/src/app.ts @@ -58,6 +58,7 @@ import {sendDiscordMessage} from "common/discord/core"; import {getMessagesCount} from "api/get-messages-count"; import {createVote} from "api/create-vote"; import {vote} from "api/vote"; +import {contact} from "api/contact"; const allowCorsUnrestricted: RequestHandler = cors({}) @@ -160,6 +161,7 @@ const handlers: { [k in APIPath]: APIHandler } = { 'create-compatibility-question': createCompatibilityQuestion, 'create-vote': createVote, 'vote': vote, + 'contact': contact, 'compatible-profiles': getCompatibleProfilesHandler, 'search-location': searchLocation, 'search-near-city': searchNearCity, diff --git a/backend/api/src/contact.ts b/backend/api/src/contact.ts new file mode 100644 index 00000000..a55bd1fd --- /dev/null +++ b/backend/api/src/contact.ts @@ -0,0 +1,41 @@ +import {APIError, APIHandler} from './helpers/endpoint' +import {createSupabaseDirectClient} from 'shared/supabase/init' +import {insert} from 'shared/supabase/utils' +import {tryCatch} from 'common/util/try-catch' +import {sendDiscordMessage} from "common/discord/core"; +import {jsonToMarkdown} from "common/md"; + +// Stores a contact message into the `contact` table +// Web sends TipTap JSON in `content`; we store it as string in `description`. +// If optional content metadata is provided, we include it; otherwise we fall back to user-centric defaults. +export const contact: APIHandler<'contact'> = async ( + {content, userId}, + _auth +) => { + const pg = createSupabaseDirectClient() + + const {error} = await tryCatch( + insert(pg, 'contact', { + user_id: userId, + content: JSON.stringify(content), + }) + ) + + if (error) throw new APIError(500, 'Failed to submit contact message') + + const continuation = async () => { + try { + const md = jsonToMarkdown(content) + const message: string = `**New Contact Message**\n${md}` + await sendDiscordMessage(message, 'contact') + } catch (e) { + console.error('Failed to send discord contact', e) + } + } + + return { + success: true, + result: {}, + continue: continuation, + } +} diff --git a/backend/api/src/create-profile.ts b/backend/api/src/create-profile.ts index 7a1de873..ba5c0077 100644 --- a/backend/api/src/create-profile.ts +++ b/backend/api/src/create-profile.ts @@ -8,30 +8,7 @@ import { updateUser } from 'shared/supabase/users' import { tryCatch } from 'common/util/try-catch' import { insert } from 'shared/supabase/utils' import {sendDiscordMessage} from "common/discord/core"; - -function extractTextFromBio(bio: any): string { - try { - const texts: string[] = [] - const visit = (node: any) => { - if (!node) return - if (Array.isArray(node)) { - for (const item of node) visit(item) - return - } - if (typeof node === 'object') { - for (const [k, v] of Object.entries(node)) { - if (k === 'text' && typeof v === 'string') texts.push(v) - else visit(v as any) - } - } - } - visit(bio) - // Remove extra whitespace and join - return texts.map((t) => t.trim()).filter(Boolean).join(' ') - } catch { - return '' - } -} +import {jsonToMarkdown} from "common/md"; export const createProfile: APIHandler<'create-profile'> = async (body, auth) => { const pg = createSupabaseDirectClient() @@ -75,7 +52,7 @@ export const createProfile: APIHandler<'create-profile'> = async (body, auth) => try { let message: string = `[**${user.name}**](https://www.compassmeet.com/${user.username}) just created a profile` if (body.bio) { - const bioText = extractTextFromBio(body.bio) + const bioText = jsonToMarkdown(body.bio) if (bioText) message += `\n > ${bioText}` } await sendDiscordMessage(message, 'members') diff --git a/backend/supabase/contact.sql b/backend/supabase/contact.sql new file mode 100644 index 00000000..78cc736a --- /dev/null +++ b/backend/supabase/contact.sql @@ -0,0 +1,14 @@ +create table if not exists + contact ( + id text default uuid_generate_v4 () not null, + created_time timestamp with time zone default now(), + user_id text, + content jsonb + ); + +-- Foreign Keys +alter table contact +add constraint contact_user_id_fkey foreign key (user_id) references users (id); + +-- Row Level Security +alter table contact enable row level security; diff --git a/common/src/api/schema.ts b/common/src/api/schema.ts index b18fc2dc..818e44b0 100644 --- a/common/src/api/schema.ts +++ b/common/src/api/schema.ts @@ -555,6 +555,16 @@ export const API = (_apiTypeCheck = { radius: z.number().min(1).max(500), }), }, + 'contact': { + method: 'POST', + authed: false, + rateLimited: true, + returns: {} as any, + props: z.object({ + content: contentSchema, + userId: z.string().optional(), + }), + }, 'get-messages-count': { method: 'GET', authed: false, diff --git a/common/src/discord/core.ts b/common/src/discord/core.ts index 7bd13de8..1ae0a4e6 100644 --- a/common/src/discord/core.ts +++ b/common/src/discord/core.ts @@ -6,6 +6,7 @@ export const sendDiscordMessage = async (content: string, channel: string) => { general: process.env.DISCORD_WEBHOOK_GENERAL, health: process.env.DISCORD_WEBHOOK_HEALTH, reports: process.env.DISCORD_WEBHOOK_REPORTS, + contact: process.env.DISCORD_WEBHOOK_CONTACT, }[channel] if (IS_DEV) webhookUrl = process.env.DISCORD_WEBHOOK_DEV diff --git a/common/src/md.ts b/common/src/md.ts new file mode 100644 index 00000000..0ac899a2 --- /dev/null +++ b/common/src/md.ts @@ -0,0 +1,89 @@ +import type { JSONContent } from '@tiptap/core' + +export function jsonToMarkdown(node: JSONContent): string { + if (!node) return '' + + // Text node + if (node.type === 'text') { + let text = node.text || '' + + if (node.marks) { + for (const mark of node.marks) { + switch (mark.type) { + case 'bold': + text = `**${text}**` + break + case 'italic': + text = `*${text}*` + break + case 'strike': + text = `~~${text}~~` + break + case 'code': + text = `\`${text}\`` + break + case 'link': + text = `[${text}](${mark.attrs?.href ?? ''})` + break + } + } + } + + return text + } + + // Non-text nodes: recursively process children + const content = (node.content || []).map(jsonToMarkdown).join('') + + switch (node.type) { + case 'paragraph': + return `${content}\n\n` + case 'heading': { + const level = node.attrs?.level || 1 + return `${'#'.repeat(level)} ${content}\n\n` + } + case 'bulletList': + return `${content}` + case 'orderedList': + return `${content}` + case 'listItem': + return `- ${content}\n` + case 'blockquote': + return content + .split('\n') + .map((line) => (line ? `> ${line}` : '')) + .join('\n') + '\n\n' + case 'codeBlock': + return `\`\`\`\n${content}\n\`\`\`\n\n` + case 'horizontalRule': + return `---\n\n` + case 'hardBreak': + return ` \n` + default: + return content + } +} + +// function extractTextFromJsonb(bio: JSONContent): string { +// try { +// const texts: string[] = [] +// const visit = (node: any) => { +// if (!node) return +// if (Array.isArray(node)) { +// for (const item of node) visit(item) +// return +// } +// if (typeof node === 'object') { +// for (const [k, v] of Object.entries(node)) { +// if (k === 'text' && typeof v === 'string') texts.push(v) +// else visit(v as any) +// } +// } +// } +// visit(bio) +// // Remove extra whitespace and join +// return texts.map((t) => t.trim()).filter(Boolean).join(' ') +// } catch { +// return '' +// } +// } diff --git a/common/src/secrets.ts b/common/src/secrets.ts index 782ca0f9..6925e03f 100644 --- a/common/src/secrets.ts +++ b/common/src/secrets.ts @@ -22,6 +22,7 @@ export const secrets = ( 'DISCORD_WEBHOOK_GENERAL', 'DISCORD_WEBHOOK_HEALTH', 'DISCORD_WEBHOOK_REPORTS', + 'DISCORD_WEBHOOK_CONTACT', // Some typescript voodoo to keep the string literal types while being not readonly. ] as const ).concat() diff --git a/common/src/supabase/schema.ts b/common/src/supabase/schema.ts index c1d46bfa..65e7186b 100644 --- a/common/src/supabase/schema.ts +++ b/common/src/supabase/schema.ts @@ -44,6 +44,35 @@ export type Database = { } Relationships: [] } + contact: { + Row: { + content: Json | null + created_time: string | null + id: string + user_id: string | null + } + Insert: { + content?: Json | null + created_time?: string | null + id?: string + user_id?: string | null + } + Update: { + content?: Json | null + created_time?: string | null + id?: string + user_id?: string | null + } + Relationships: [ + { + foreignKeyName: 'contact_user_id_fkey' + columns: ['user_id'] + isOneToOne: false + referencedRelation: 'users' + referencedColumns: ['id'] + } + ] + } love_answers: { Row: { created_time: string diff --git a/web/components/contact.tsx b/web/components/contact.tsx new file mode 100644 index 00000000..95467ce2 --- /dev/null +++ b/web/components/contact.tsx @@ -0,0 +1,57 @@ +import {Col} from 'web/components/layout/col' +import {Row} from 'web/components/layout/row' +import {useUser} from 'web/hooks/use-user' +import {TextEditor, useTextEditor} from "web/components/widgets/editor"; +import {JSONContent} from "@tiptap/core"; +import {MAX_DESCRIPTION_LENGTH} from "common/envs/constants"; +import {Button} from "web/components/buttons/button"; +import {api} from "web/lib/api"; +import {Title} from "web/components/widgets/title"; +import toast from "react-hot-toast"; + +export function ContactComponent() { + const user = useUser() + + const editor = useTextEditor({ + max: MAX_DESCRIPTION_LENGTH, + defaultValue: '', + placeholder: 'Contact us here...', + }) + + const hideButton = editor?.getText().length == 0 + + return ( + + Contact + +
+ +
+ {!hideButton && ( + + + + )} + + + ) +} diff --git a/web/components/love-page.tsx b/web/components/love-page.tsx index bf67a4ab..2b9f457f 100644 --- a/web/components/love-page.tsx +++ b/web/components/love-page.tsx @@ -25,6 +25,7 @@ import {Profile} from 'common/love/profile' import {NotificationsIcon, SolidNotificationsIcon} from './notifications-icon' import {IS_MAINTENANCE} from "common/constants"; import {MdThumbUp} from "react-icons/md"; +import {FaEnvelope} from "react-icons/fa"; export function LovePage(props: { trackPageView: string | false @@ -115,6 +116,7 @@ const Messages = {name: 'Messages', href: '/messages', icon: PrivateMessagesIcon const Social = {name: 'Social', href: '/social', icon: LinkIcon}; const Organization = {name: 'Organization', href: '/organization', icon: GlobeAltIcon}; const Vote = {name: 'Vote', href: '/vote', icon: MdThumbUp}; +const Contact = {name: 'Contact', href: '/contact', icon: FaEnvelope}; const base = [ About, @@ -122,6 +124,7 @@ const base = [ Vote, Social, Organization, + Contact, ] function getBottomNavigation(user: User, profile: Profile | null | undefined) { diff --git a/web/pages/contact.tsx b/web/pages/contact.tsx new file mode 100644 index 00000000..8b6534a0 --- /dev/null +++ b/web/pages/contact.tsx @@ -0,0 +1,20 @@ +import {LovePage} from 'web/components/love-page' +import {SEO} from 'web/components/SEO' +import {ContactComponent} from "web/components/contact"; + + +export default function ContactPage() { + return ( + + + + + ) +}