This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: add onboarding process

+467 -182
+10
src/features/takes/handlers/help.ts
··· 36 36 type: "button", 37 37 text: { 38 38 type: "plain_text", 39 + text: "⚙️ Settings", 40 + emoji: true, 41 + }, 42 + value: "settings", 43 + action_id: "takes_settings", 44 + }, 45 + { 46 + type: "button", 47 + text: { 48 + type: "plain_text", 39 49 text: "📋 History", 40 50 emoji: true, 41 51 },
+10
src/features/takes/handlers/history.ts
··· 68 68 type: "button", 69 69 text: { 70 70 type: "plain_text", 71 + text: "⚙️ Settings", 72 + emoji: true, 73 + }, 74 + value: "settings", 75 + action_id: "takes_settings", 76 + }, 77 + { 78 + type: "button", 79 + text: { 80 + type: "plain_text", 71 81 text: "🔄 Refresh", 72 82 emoji: true, 73 83 },
+18 -11
src/features/takes/handlers/home.ts
··· 1 1 import type { MessageResponse } from "../types"; 2 2 import { db } from "../../../libs/db"; 3 - import { takes as takesTable } from "../../../libs/schema"; 3 + import { takes as takesTable, users as usersTable } from "../../../libs/schema"; 4 4 import { eq, and, desc } from "drizzle-orm"; 5 5 import { prettyPrintTime } from "../../../libs/time"; 6 6 7 7 export default async function handleHome( 8 8 userId: string, 9 9 ): Promise<MessageResponse> { 10 - const takes = await db 11 - .select() 12 - .from(takesTable) 13 - .where(and(eq(takesTable.userId, userId))) 14 - .orderBy(desc(takesTable.createdAt)); 10 + const userFromDB = ( 11 + await db 12 + .select({ totalTakesTime: usersTable.totalTakesTime }) 13 + .from(usersTable) 14 + .where(eq(usersTable.id, userId)) 15 + )[0]; 15 16 16 - const takeTimeMs = takes.reduce( 17 - (acc, take) => acc + take.elapsedTimeMs * Number(take.multiplier), 18 - 0, 19 - ); 20 - const takeTime = prettyPrintTime(takeTimeMs); 17 + const takeTime = prettyPrintTime(userFromDB?.totalTakesTime || 0); 21 18 22 19 return { 23 20 text: `You have logged ${takeTime} of takes!`, ··· 49 46 }, 50 47 value: "history", 51 48 action_id: "takes_history", 49 + }, 50 + { 51 + type: "button", 52 + text: { 53 + type: "plain_text", 54 + text: "⚙️ Settings", 55 + emoji: true, 56 + }, 57 + value: "settings", 58 + action_id: "takes_settings", 52 59 }, 53 60 { 54 61 type: "button",
+260
src/features/takes/handlers/settings.ts
··· 1 + import type { UploadedFile } from "slack-edge"; 2 + import { slackApp, slackClient } from "../../../index"; 3 + import { db } from "../../../libs/db"; 4 + import { eq } from "drizzle-orm"; 5 + import { users as usersTable } from "../../../libs/schema"; 6 + import { 7 + getHackatimeApiUrl, 8 + getHackatimeName, 9 + getHackatimeVersion, 10 + HACKATIME_VERSIONS, 11 + type HackatimeVersion, 12 + } from "../../../libs/hackatime"; 13 + 14 + export async function handleSettings( 15 + triggerID: string, 16 + user: string, 17 + prefill = false, 18 + ) { 19 + let initialValues: { 20 + project_name: string; 21 + project_description: string; 22 + repo_link: string | undefined; 23 + demo_link: string | undefined; 24 + hackatime_version: string; 25 + } = { 26 + project_name: "", 27 + project_description: "", 28 + repo_link: undefined, 29 + demo_link: undefined, 30 + hackatime_version: "v2", 31 + }; 32 + 33 + if (prefill) { 34 + try { 35 + // Check if user already has a project in the database 36 + const existingUser = ( 37 + await db 38 + .select() 39 + .from(usersTable) 40 + .where(eq(usersTable.id, user)) 41 + )[0]; 42 + 43 + if (existingUser) { 44 + initialValues = { 45 + project_name: existingUser.projectName, 46 + project_description: existingUser.projectDescription, 47 + repo_link: existingUser.repoLink || undefined, 48 + demo_link: existingUser.demoLink || undefined, 49 + hackatime_version: getHackatimeVersion( 50 + existingUser.hackatimeBaseAPI, 51 + ), 52 + }; 53 + } 54 + } catch (error) { 55 + console.error("Error prefilling form:", error); 56 + } 57 + } 58 + 59 + await slackClient.views.open({ 60 + trigger_id: triggerID, 61 + view: { 62 + type: "modal", 63 + title: { 64 + type: "plain_text", 65 + text: "Setup Project", 66 + }, 67 + submit: { 68 + type: "plain_text", 69 + text: "Submit", 70 + }, 71 + clear_on_close: true, 72 + callback_id: "takes_setup_submit", 73 + blocks: [ 74 + { 75 + type: "input", 76 + block_id: "project_name", 77 + label: { 78 + type: "plain_text", 79 + text: "Project Name", 80 + }, 81 + element: { 82 + type: "plain_text_input", 83 + action_id: "project_name_input", 84 + initial_value: initialValues.project_name || "", 85 + placeholder: { 86 + type: "plain_text", 87 + text: "Enter your project name", 88 + }, 89 + }, 90 + }, 91 + { 92 + type: "input", 93 + block_id: "project_description", 94 + label: { 95 + type: "plain_text", 96 + text: "Project Description", 97 + }, 98 + element: { 99 + type: "plain_text_input", 100 + action_id: "project_description_input", 101 + multiline: true, 102 + initial_value: initialValues.project_description || "", 103 + placeholder: { 104 + type: "plain_text", 105 + text: "Describe your project", 106 + }, 107 + }, 108 + }, 109 + { 110 + type: "input", 111 + block_id: "project_banner", 112 + label: { 113 + type: "plain_text", 114 + text: `Banner Image${prefill ? " (this will replace your current banner)" : ""}`, 115 + }, 116 + element: { 117 + type: "file_input", 118 + action_id: "project_banner_input", 119 + }, 120 + optional: prefill, 121 + }, 122 + { 123 + type: "input", 124 + block_id: "repo_link", 125 + optional: true, 126 + label: { 127 + type: "plain_text", 128 + text: "Repository Link", 129 + }, 130 + element: { 131 + type: "plain_text_input", 132 + action_id: "repo_link_input", 133 + initial_value: initialValues.repo_link || "", 134 + placeholder: { 135 + type: "plain_text", 136 + text: "Optional: Add a link to your repository", 137 + }, 138 + }, 139 + }, 140 + { 141 + type: "input", 142 + block_id: "demo_link", 143 + optional: true, 144 + label: { 145 + type: "plain_text", 146 + text: "Demo Link", 147 + }, 148 + element: { 149 + type: "plain_text_input", 150 + action_id: "demo_link_input", 151 + initial_value: initialValues.demo_link || "", 152 + placeholder: { 153 + type: "plain_text", 154 + text: "Optional: Add a link to your demo", 155 + }, 156 + }, 157 + }, 158 + { 159 + type: "input", 160 + block_id: "hackatime_version", 161 + label: { 162 + type: "plain_text", 163 + text: "Hackatime Version", 164 + }, 165 + element: { 166 + type: "static_select", 167 + action_id: "hackatime_version_input", 168 + initial_option: { 169 + text: { 170 + type: "plain_text", 171 + text: getHackatimeName( 172 + initialValues.hackatime_version as HackatimeVersion, 173 + ), 174 + }, 175 + value: initialValues.hackatime_version, 176 + }, 177 + options: Object.values(HACKATIME_VERSIONS).map((v) => ({ 178 + text: { 179 + type: "plain_text", 180 + text: getHackatimeName(v.id), 181 + }, 182 + value: v.id, 183 + })), 184 + }, 185 + }, 186 + ], 187 + }, 188 + }); 189 + } 190 + 191 + export async function setupSubmitListener() { 192 + slackApp.view("takes_setup_submit", async ({ payload, body }) => { 193 + if (payload.type !== "view_submission") return; 194 + const values = payload.view.state.values; 195 + const userId = body.user.id; 196 + 197 + const file = values.project_banner?.project_banner_input 198 + ?.files?.[0] as UploadedFile; 199 + try { 200 + // If file is already public, use it directly 201 + const fileData = file.is_public 202 + ? file 203 + : ( 204 + await slackClient.files.sharedPublicURL({ 205 + file: file.id, 206 + token: process.env.SLACK_USER_TOKEN, 207 + }) 208 + ).file; 209 + 210 + const html = await ( 211 + await fetch(fileData?.permalink_public as string) 212 + ).text(); 213 + const projectBannerUrl = html.match( 214 + /https:\/\/files.slack.com\/files-pri\/[^"]+pub_secret=([^"&]*)/, 215 + )?.[0]; 216 + 217 + const hackatimeVersion = values.hackatime_version 218 + ?.hackatime_version_input?.selected_option 219 + ?.value as HackatimeVersion; 220 + 221 + await db 222 + .insert(usersTable) 223 + .values({ 224 + id: userId, 225 + projectName: values.project_name?.project_name_input 226 + ?.value as string, 227 + projectDescription: values.project_description 228 + ?.project_description_input?.value as string, 229 + projectBannerUrl, 230 + repoLink: values.project_link?.repo_link?.value as 231 + | string 232 + | undefined, 233 + demoLink: values.project_link?.demo_link?.value as 234 + | string 235 + | undefined, 236 + hackatimeBaseAPI: getHackatimeApiUrl(hackatimeVersion), 237 + }) 238 + .onConflictDoUpdate({ 239 + target: usersTable.id, 240 + set: { 241 + projectName: values.project_name?.project_name_input 242 + ?.value as string, 243 + projectDescription: values.project_description 244 + ?.project_description_input?.value as string, 245 + projectBannerUrl, 246 + repoLink: values.repo_link?.repo_link_input?.value as 247 + | string 248 + | undefined, 249 + demoLink: values.demo_link?.demo_link_input?.value as 250 + | string 251 + | undefined, 252 + hackatimeBaseAPI: getHackatimeApiUrl(hackatimeVersion), 253 + }, 254 + }); 255 + } catch (error) { 256 + console.error("Error processing file:", error); 257 + throw error; 258 + } 259 + }); 260 + }
-115
src/features/takes/handlers/setup.ts
··· 1 - import type { UploadedFile } from "slack-edge"; 2 - import { slackApp, slackClient } from "../../../index"; 3 - import { db } from "../../../libs/db"; 4 - import { users as usersTable } from "../../../libs/schema"; 5 - 6 - export async function handleSetup(triggerID: string) { 7 - await slackClient.views.open({ 8 - trigger_id: triggerID, 9 - view: { 10 - type: "modal", 11 - title: { 12 - type: "plain_text", 13 - text: "Setup Project", 14 - }, 15 - submit: { 16 - type: "plain_text", 17 - text: "Submit", 18 - }, 19 - clear_on_close: true, 20 - callback_id: "takes_setup_submit", 21 - blocks: [ 22 - { 23 - type: "input", 24 - block_id: "project_name", 25 - label: { 26 - type: "plain_text", 27 - text: "Project Name", 28 - }, 29 - element: { 30 - type: "plain_text_input", 31 - action_id: "project_name_input", 32 - placeholder: { 33 - type: "plain_text", 34 - text: "Enter your project name", 35 - }, 36 - }, 37 - }, 38 - { 39 - type: "input", 40 - block_id: "project_description", 41 - label: { 42 - type: "plain_text", 43 - text: "Project Description", 44 - }, 45 - element: { 46 - type: "plain_text_input", 47 - action_id: "project_description_input", 48 - multiline: true, 49 - placeholder: { 50 - type: "plain_text", 51 - text: "Describe your project", 52 - }, 53 - }, 54 - }, 55 - { 56 - type: "input", 57 - block_id: "project_banner", 58 - label: { 59 - type: "plain_text", 60 - text: "Project Banner Image", 61 - }, 62 - element: { 63 - type: "file_input", 64 - action_id: "project_banner_input", 65 - }, 66 - }, 67 - ], 68 - }, 69 - }); 70 - } 71 - 72 - export async function setupSubmitListener() { 73 - slackApp.view( 74 - "takes_setup_submit", 75 - async () => Promise.resolve(), 76 - async ({ payload, body }) => { 77 - if (payload.type !== "view_submission") return; 78 - const values = payload.view.state.values; 79 - const userId = body.user.id; 80 - 81 - const file = values.project_banner?.project_banner_input 82 - ?.files?.[0] as UploadedFile; 83 - try { 84 - // If file is already public, use it directly 85 - const fileData = file.is_public 86 - ? file 87 - : ( 88 - await slackClient.files.sharedPublicURL({ 89 - file: file.id, 90 - token: process.env.SLACK_USER_TOKEN, 91 - }) 92 - ).file; 93 - 94 - const html = await ( 95 - await fetch(fileData?.permalink_public as string) 96 - ).text(); 97 - const projectBannerUrl = html.match( 98 - /https:\/\/files.slack.com\/files-pri\/[^"]+pub_secret=([^"&]*)/, 99 - )?.[0]; 100 - 101 - await db.insert(usersTable).values({ 102 - id: userId, 103 - projectName: values.project_name?.project_name_input 104 - ?.value as string, 105 - projectDescription: values.project_description 106 - ?.project_description_input?.value as string, 107 - projectBannerUrl, 108 - }); 109 - } catch (error) { 110 - console.error("Error processing file:", error); 111 - throw error; 112 - } 113 - }, 114 - ); 115 - }
+34 -3
src/features/takes/services/upload.ts src/features/takes/handlers/upload.ts
··· 25 25 await slackClient.chat.postMessage({ 26 26 channel: payload.channel, 27 27 thread_ts: payload.ts, 28 - text: "we don't have a project for you; set one up in the web ui or by running `/takes`", 28 + text: "We don't have a project for you; set one up by clicking the button below or by running `/takes`", 29 + blocks: [ 30 + { 31 + type: "section", 32 + text: { 33 + type: "mrkdwn", 34 + text: "We don't have a project for you; set one up by clicking the button below or by running `/takes`", 35 + }, 36 + }, 37 + { 38 + type: "actions", 39 + elements: [ 40 + { 41 + type: "button", 42 + text: { 43 + type: "plain_text", 44 + text: "setup your project", 45 + }, 46 + action_id: "takes_setup", 47 + }, 48 + ], 49 + }, 50 + { 51 + type: "context", 52 + elements: [ 53 + { 54 + type: "plain_text", 55 + text: "don't forget to resend your update after setting up your project!", 56 + }, 57 + ], 58 + }, 59 + ], 29 60 }); 30 61 return; 31 62 } ··· 100 131 const timeSpentMs = 60000; 101 132 102 133 await db.insert(takesTable).values({ 103 - id: payload.ts, 134 + id: Bun.randomUUIDv7(), 104 135 userId: user, 105 136 ts: payload.ts, 106 137 notes: markdownText, ··· 123 154 type: "section", 124 155 text: { 125 156 type: "mrkdwn", 126 - text: `:inbox_tray: saved! ${mediaUrls.length > 0 ? "uploaded media and " : ""}saved your notes`, 157 + text: `:inbox_tray: ${mediaUrls.length > 0 ? "uploaded media and " : ""}saved your notes!`, 127 158 }, 128 159 }, 129 160 ],
+68 -49
src/features/takes/setup/actions.ts
··· 3 3 import handleHelp from "../handlers/help"; 4 4 import { handleHistory } from "../handlers/history"; 5 5 import handleHome from "../handlers/home"; 6 - import { setupSubmitListener } from "../handlers/setup"; 7 - import upload from "../services/upload"; 6 + import { handleSettings, setupSubmitListener } from "../handlers/settings"; 7 + import upload from "../handlers/upload"; 8 8 import type { MessageResponse } from "../types"; 9 9 import * as Sentry from "@sentry/bun"; 10 10 11 11 export default function setupActions() { 12 12 // Handle button actions 13 - slackApp.action(/^takes_(\w+)$/, async ({ payload, context }) => { 14 - try { 15 - const userId = payload.user.id; 16 - const actionId = payload.actions[0]?.action_id as string; 17 - const command = actionId.replace("takes_", ""); 13 + slackApp.action( 14 + /^takes_(\w+)$/, 15 + async () => Promise.resolve(), 16 + async ({ payload, context }) => { 17 + try { 18 + const userId = payload.user.id; 19 + const actionId = payload.actions[0]?.action_id as string; 20 + const command = actionId.replace("takes_", ""); 18 21 19 - let response: MessageResponse | undefined; 22 + let response: MessageResponse | undefined; 20 23 21 - // Route to the appropriate handler function 22 - switch (command) { 23 - case "history": 24 - response = await handleHistory(userId); 25 - break; 26 - case "help": 27 - response = await handleHelp(); 28 - break; 29 - case "home": 30 - response = await handleHome(userId); 31 - break; 32 - default: 33 - response = await handleHome(userId); 34 - break; 35 - } 24 + // Route to the appropriate handler function 25 + switch (command) { 26 + case "history": 27 + response = await handleHistory(userId); 28 + break; 29 + case "help": 30 + response = await handleHelp(); 31 + break; 32 + case "home": 33 + response = await handleHome(userId); 34 + break; 35 + case "settings": 36 + await handleSettings( 37 + context.triggerId as string, 38 + userId, 39 + true, 40 + ); 41 + if (context.respond) 42 + await context.respond({ delete_original: true }); 43 + return; 44 + case "setup": 45 + await handleSettings( 46 + context.triggerId as string, 47 + userId, 48 + ); 49 + return; 50 + default: 51 + response = await handleHome(userId); 52 + break; 53 + } 36 54 37 - // Send the response 38 - if (response && context.respond) { 39 - await context.respond(response); 40 - } 41 - } catch (error) { 42 - if (error instanceof Error) 43 - blog( 44 - `Error in \`${payload.actions[0]?.action_id}\` action: ${error.message}`, 45 - "error", 46 - ); 47 - 48 - // Capture the error in Sentry 49 - Sentry.captureException(error, { 50 - extra: { 51 - actionId: payload.actions[0]?.action_id, 52 - userId: payload.user.id, 53 - channelId: context.channelId, 54 - }, 55 - }); 55 + // Send the response 56 + if (response && context.respond) { 57 + await context.respond(response); 58 + } 59 + } catch (error) { 60 + if (error instanceof Error) 61 + blog( 62 + `Error in \`${payload.actions[0]?.action_id}\` action: ${error.message}`, 63 + "error", 64 + ); 56 65 57 - // Respond with error message to user 58 - if (context.respond) { 59 - await context.respond({ 60 - text: "An error occurred while processing your request. Please stand by while we try to put out the fire.", 61 - response_type: "ephemeral", 66 + // Capture the error in Sentry 67 + Sentry.captureException(error, { 68 + extra: { 69 + actionId: payload.actions[0]?.action_id, 70 + userId: payload.user.id, 71 + channelId: context.channelId, 72 + }, 62 73 }); 74 + 75 + // Respond with error message to user 76 + if (context.respond) { 77 + await context.respond({ 78 + text: "An error occurred while processing your request. Please stand by while we try to put out the fire.", 79 + response_type: "ephemeral", 80 + }); 81 + } 63 82 } 64 - } 65 - }); 83 + }, 84 + ); 66 85 67 86 // setup the upload actions 68 87 try {
+10 -2
src/features/takes/setup/commands.ts
··· 8 8 import { db } from "../../../libs/db"; 9 9 import { users as usersTable } from "../../../libs/schema"; 10 10 import { eq } from "drizzle-orm"; 11 - import { handleSetup } from "../handlers/setup"; 11 + import { handleSettings } from "../handlers/settings"; 12 12 13 13 export default function setupCommands() { 14 14 // Main command handler 15 15 slackApp.command( 16 16 environment === "dev" ? "/takes-dev" : "/takes", 17 + async () => Promise.resolve(), 17 18 async ({ payload, context }): Promise<void> => { 18 19 try { 19 20 const userId = payload.user_id; ··· 30 31 .where(eq(usersTable.id, userId)); 31 32 32 33 if (userFromDB.length === 0) { 33 - await handleSetup(context.triggerId as string); 34 + await handleSettings(context.triggerId as string, userId); 34 35 return; 35 36 } 36 37 ··· 42 43 case "help": 43 44 response = await handleHelp(); 44 45 break; 46 + case "settings": 47 + await handleSettings( 48 + context.triggerId as string, 49 + userId, 50 + true, 51 + ); 52 + return; 45 53 default: 46 54 response = await handleHome(userId); 47 55 break;
+49
src/libs/hackatime.ts
··· 1 + /** 2 + * Maps Hackatime version identifiers to their corresponding data 3 + */ 4 + export const HACKATIME_VERSIONS = { 5 + v1: { 6 + id: "v1", 7 + name: "Hackatime", 8 + apiUrl: "https://waka.hackclub.com/api", 9 + }, 10 + v2: { 11 + id: "v2", 12 + name: "Hackatime v2", 13 + apiUrl: "https://hackatime.hackclub.com/api", 14 + }, 15 + } as const; 16 + 17 + export type HackatimeVersion = keyof typeof HACKATIME_VERSIONS; 18 + 19 + /** 20 + * Converts a Hackatime version identifier to its full API URL 21 + * @param version The version identifier (v1 or v2) 22 + * @returns The corresponding API URL 23 + */ 24 + export function getHackatimeApiUrl(version: HackatimeVersion): string { 25 + return HACKATIME_VERSIONS[version].apiUrl; 26 + } 27 + 28 + /** 29 + * Gets the fancy name for a Hackatime version 30 + * @param version The version identifier (v1 or v2) 31 + * @returns The fancy display name for the version 32 + */ 33 + export function getHackatimeName(version: HackatimeVersion): string { 34 + return HACKATIME_VERSIONS[version].name; 35 + } 36 + 37 + /** 38 + * Determines which Hackatime version is being used based on the API URL 39 + * @param apiUrl The full Hackatime API URL 40 + * @returns The version identifier (v1 or v2), defaulting to v2 if not recognized 41 + */ 42 + export function getHackatimeVersion(apiUrl: string): HackatimeVersion { 43 + for (const [version, data] of Object.entries(HACKATIME_VERSIONS)) { 44 + if (apiUrl === data.apiUrl) { 45 + return version as HackatimeVersion; 46 + } 47 + } 48 + return "v2"; 49 + }
+8 -2
src/libs/schema.ts
··· 16 16 }); 17 17 18 18 export const users = pgTable("users", { 19 - id: text("id").primaryKey(), 19 + id: text("id") 20 + .primaryKey() 21 + .$defaultFn(() => Bun.randomUUIDv7()), 20 22 totalTakesTime: integer("total_takes_time").default(0).notNull(), 21 23 hackatimeKeys: text("hackatime_keys").notNull().default("[]"), 22 24 projectName: text("project_name").notNull().default(""), 23 25 projectDescription: text("project_description").notNull().default(""), 24 26 projectBannerUrl: text("project_banner_url").notNull().default(""), 25 - usingHackatimeV2: boolean().notNull().default(true), 27 + hackatimeBaseAPI: text("hackatime_base_api") 28 + .notNull() 29 + .default("https://hackatime.hackclub.com/api"), 30 + repoLink: text("repo_link"), 31 + demoLink: text("demo_link"), 26 32 }); 27 33 28 34 export async function setupTriggers(pool: Pool) {