This repository has no description
0

Configure Feed

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

feat: add basic takes upload system

+166 -408
+2
README.md
··· 30 30 SLACK_SPAM_CHANNEL="C069N64PW4A" 31 31 SLACK_LOG_CHANNEL="C08KX2YNN87" 32 32 SLACK_REVIEW_CHANNEL="C07P0CXT08H" 33 + SLACK_LISTEN_CHANNEL="C08NEE6FVJT" 33 34 NODE_ENV="dev" 34 35 SLACK_USER_TOKEN="xoxp-xxxxx-xxxxx-xxxxx-xxxxx" 35 36 API_URL="https://casual-renewing-reptile.ngrok-free.app" 36 37 SENTRY_DSN="https://xxxxxx@xxxxxx.ingest.us.sentry.io/xxxx" 38 + DATABASE_URL="postgres://username:password@host:5432/smokie" 37 39 ``` 38 40 39 41 ## 📜 License
+3
bun.lock
··· 14 14 "pg": "^8.14.1", 15 15 "react": "^19.1.0", 16 16 "react-dom": "^19.1.0", 17 + "react-masonry-css": "^1.0.16", 17 18 "slack-edge": "^1.3.7", 18 19 "yaml": "^2.7.1", 19 20 }, ··· 266 267 "react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="], 267 268 268 269 "react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="], 270 + 271 + "react-masonry-css": ["react-masonry-css@1.0.16", "", { "peerDependencies": { "react": ">=16.0.0" } }, "sha512-KSW0hR2VQmltt/qAa3eXOctQDyOu7+ZBevtKgpNDSzT7k5LA/0XntNa9z9HKCdz3QlxmJHglTZ18e4sX4V8zZQ=="], 269 272 270 273 "require-in-the-middle": ["require-in-the-middle@7.5.2", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3", "resolve": "^1.22.8" } }, "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ=="], 271 274
+1
package.json
··· 31 31 "pg": "^8.14.1", 32 32 "react": "^19.1.0", 33 33 "react-dom": "^19.1.0", 34 + "react-masonry-css": "^1.0.16", 34 35 "slack-edge": "^1.3.7", 35 36 "yaml": "^2.7.1" 36 37 }
+32 -10
src/features/frontend/app.tsx
··· 2 2 import { prettyPrintTime } from "../../libs/time"; 3 3 import { fetchUserData } from "../../libs/cachet"; 4 4 import type { RecentTake } from "../api/routes/recentTakes"; 5 + import Masonry from "react-masonry-css"; 5 6 6 7 export function App() { 7 8 const [takes, setTakes] = useState<RecentTake[]>([]); ··· 42 43 getTakes(); 43 44 }, []); 44 45 46 + const breakpointColumns = { 47 + default: 4, 48 + 1100: 3, 49 + 700: 2, 50 + 500: 1, 51 + }; 52 + 45 53 return ( 46 54 <div className="container"> 47 55 <h1 className="title">Recent Takes</h1> 48 - <div className="takes-grid"> 56 + <Masonry 57 + breakpointCols={breakpointColumns} 58 + className="takes-grid" 59 + columnClassName="takes-grid-column" 60 + > 49 61 {takes.map((take) => ( 50 62 <div key={take.id} className="take-card"> 51 63 <div className="take-header"> ··· 81 93 </div> 82 94 83 95 {take.mediaUrls?.map((url: string, index: number) => { 84 - const isVideo = url.endsWith(".mp4"); 96 + // More robust video detection for Slack-style URLs 97 + const isVideo = 98 + /\.(mp4|mov|webm|ogg)/i.test(url) || 99 + (url.includes("files.slack.com") && 100 + url.includes("download")); 101 + const contentType = isVideo ? "video" : "image"; 102 + 85 103 return ( 86 104 <div 87 105 key={`media-${take.id}-${index}`} 88 - className={ 89 - isVideo 90 - ? "video-container" 91 - : "image-container" 92 - } 106 + className={`${contentType}-container`} 93 107 > 94 108 {isVideo ? ( 95 - <video controls className="take-video"> 109 + <video 110 + controls 111 + className="take-video" 112 + preload="metadata" 113 + playsInline 114 + > 96 115 <source 97 116 src={url} 98 117 type="video/mp4" ··· 102 121 src="" 103 122 label="Captions" 104 123 /> 124 + Your browser does not support the 125 + video tag. 105 126 </video> 106 127 ) : ( 107 128 <img 108 129 src={url} 109 - alt="" 130 + alt={`Media content ${index + 1}`} 110 131 className="take-image" 132 + loading="lazy" 111 133 /> 112 134 )} 113 135 </div> ··· 115 137 })} 116 138 </div> 117 139 ))} 118 - </div> 140 + </Masonry> 119 141 </div> 120 142 ); 121 143 }
+16 -21
src/features/frontend/styles.css
··· 23 23 } 24 24 25 25 .takes-grid { 26 - display: grid; 27 - grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); 28 - gap: 2rem; 26 + display: flex; 27 + margin-left: -2rem; /* Compensate for column gap */ 28 + width: auto; 29 + } 30 + 31 + .takes-grid-column { 32 + padding-left: 2rem; /* Column gap */ 33 + background-clip: padding-box; 29 34 } 30 35 31 36 .take-card { ··· 33 38 border-radius: 12px; 34 39 padding: 1.5rem; 35 40 box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); 41 + margin-bottom: 2rem; 36 42 } 37 43 38 44 .take-header { ··· 68 74 align-items: center; 69 75 } 70 76 71 - .status-badge { 72 - padding: 0.25rem 0.75rem; 73 - border-radius: 999px; 74 - } 75 - 76 - .status-approved { 77 - background: #e6f4ea; 78 - color: #1e7e34; 79 - } 80 - 81 - .status-uploaded { 82 - background: #fff8c1; 83 - color: #f2a30a; 84 - } 85 - 86 77 .take-meta { 87 78 margin-bottom: 1rem; 88 79 } ··· 97 88 margin-right: 0.5rem; 98 89 min-width: 80px; 99 90 } 100 - 101 - .video-container { 91 + .video-container, 92 + .image-container { 102 93 margin-top: 1rem; 103 94 border-radius: 8px; 104 95 overflow: hidden; 105 96 } 106 97 107 - .take-video { 98 + .take-video, 99 + .take-image { 108 100 width: 100%; 101 + height: auto; 109 102 display: block; 103 + border-radius: 8px; 104 + max-height: 40rem; 110 105 }
+81 -334
src/features/takes/services/upload.ts
··· 1 1 import { slackApp, slackClient } from "../../../index"; 2 2 import { db } from "../../../libs/db"; 3 3 import { takes as takesTable } from "../../../libs/schema"; 4 - import { eq, and } from "drizzle-orm"; 5 - import { generateSlackDate, prettyPrintTime } from "../../../libs/time"; 6 4 import * as Sentry from "@sentry/bun"; 7 5 8 6 export default async function upload() { 9 - slackApp.anyMessage(async ({ payload }) => { 7 + slackApp.anyMessage(async ({ payload, context }) => { 10 8 try { 11 - if (payload.subtype !== "file_share") return; 12 - const user = payload.user; 9 + const user = payload.user as string; 13 10 14 - if (!user) return; 15 - 16 - if (!payload.channel.startsWith("D")) return; 11 + if ( 12 + payload.subtype === "bot_message" || 13 + payload.subtype === "thread_broadcast" || 14 + payload.channel !== process.env.SLACK_LISTEN_CHANNEL 15 + ) 16 + return; 17 17 18 - const takesNeedUpload = await db 19 - .select() 20 - .from(takesTable) 21 - .where( 22 - and( 23 - eq(takesTable.userId, payload.user as string), 24 - eq(takesTable.ts, payload.thread_ts as string), 25 - eq(takesTable.media, "[]"), 26 - ), 27 - ); 18 + // Convert Slack formatting to markdown 19 + const replaceUserMentions = async (text: string) => { 20 + const regex = /<@([A-Z0-9]+)>/g; 21 + const matches = text.match(regex); 28 22 29 - if (takesNeedUpload.length === 0) return; 23 + if (!matches) return text; 30 24 31 - const take = takesNeedUpload[0]; 25 + let result = text; 26 + for (const match of matches) { 27 + const userId = match.match(/[A-Z0-9]+/)?.[0]; 28 + if (!userId) continue; 32 29 33 - if (!payload.files || !take) return; 30 + try { 31 + const userInfo = await slackClient.users.info({ 32 + user: userId, 33 + }); 34 + const name = 35 + userInfo.user?.profile?.display_name || 36 + userInfo.user?.real_name || 37 + userId; 38 + result = result.replace(match, `@${name}`); 39 + } catch (e) { 40 + result = result.replace(match, `@${userId}`); 41 + } 42 + } 43 + return result; 44 + }; 34 45 35 - const file = payload.files[0]; 46 + const markdownText = (await replaceUserMentions(payload.text)) 47 + .replace(/\*(.*?)\*/g, "**$1**") // Bold 48 + .replace(/_(.*?)_/g, "*$1*") // Italic 49 + .replace(/~(.*?)~/g, "~~$1~~") // Strikethrough 50 + .replace(/<(https?:\/\/[^|]+)\|([^>]+)>/g, "[$2]($1)"); // Links 36 51 37 - if (!file || !file.id || !file.thumb_video || !file.mp4) { 38 - await slackClient.reactions.add({ 39 - channel: payload.channel, 40 - timestamp: payload.ts as string, 41 - name: "no", 42 - }); 52 + const mediaUrls = []; 43 53 44 - slackClient.chat.postMessage({ 45 - channel: payload.channel, 46 - thread_ts: payload.thread_ts, 47 - text: "that's not a video file? 🤔", 48 - }); 49 - return; 50 - } 54 + if (payload.files && payload.files.length > 0) { 55 + for (const file of payload.files) { 56 + if ( 57 + file.mimetype && 58 + (file.mimetype.startsWith("image/") || 59 + file.mimetype.startsWith("video/")) 60 + ) { 61 + const fileres = await slackClient.files.sharedPublicURL( 62 + { 63 + file: file.id as string, 64 + token: process.env.SLACK_USER_TOKEN, 65 + }, 66 + ); 51 67 52 - const fileres = await slackClient.files.sharedPublicURL({ 53 - file: file.id, 54 - token: process.env.SLACK_USER_TOKEN, 55 - }); 68 + const fetchRes = await fetch( 69 + fileres.file?.permalink_public as string, 70 + ); 71 + const html = await fetchRes.text(); 72 + const match = html.match( 73 + /https:\/\/files.slack.com\/files-pri\/[^"]+pub_secret=([^"&]*)/, 74 + ); 75 + const filePublicUrl = match?.[0]; 56 76 57 - const fetchRes = await fetch( 58 - fileres.file?.permalink_public as string, 59 - ); 60 - const html = await fetchRes.text(); 61 - const match = html.match(/src="([^"]*\.mp4[^"]*)"/); 62 - const takePublicUrl = match?.[1]; 77 + if (filePublicUrl) { 78 + mediaUrls.push(filePublicUrl); 79 + } 80 + } 81 + } 82 + } 63 83 64 - const takeUploadedAt = new Date(); 84 + // fetch time spent on project via hackatime 85 + const timeSpentMs = 60000; 65 86 66 - await db 67 - .update(takesTable) 68 - .set({ 69 - media: JSON.stringify([takePublicUrl]), 70 - }) 71 - .where(eq(takesTable.id, take.id)); 87 + await db.insert(takesTable).values({ 88 + id: payload.ts, 89 + userId: user, 90 + ts: payload.ts, 91 + notes: markdownText, 92 + media: JSON.stringify(mediaUrls), 93 + elapsedTimeMs: timeSpentMs, 94 + }); 72 95 73 96 await slackClient.reactions.add({ 74 97 channel: payload.channel, 75 - timestamp: payload.ts as string, 98 + timestamp: payload.ts, 76 99 name: "fire", 77 100 }); 78 101 79 102 await slackClient.chat.postMessage({ 80 103 channel: payload.channel, 81 - thread_ts: payload.thread_ts, 82 - text: ":video_camera: uploaded! leme send this to the team for review real quick", 104 + thread_ts: payload.ts, 105 + text: ":inbox_tray: saved! thanks for the upload", 83 106 blocks: [ 84 107 { 85 108 type: "section", 86 109 text: { 87 110 type: "mrkdwn", 88 - text: ":video_camera: uploaded! leme send this to the team for review real quick", 111 + text: `:inbox_tray: saved! ${mediaUrls.length > 0 ? "uploaded media and " : ""}saved your notes`, 89 112 }, 90 113 }, 91 - { 92 - type: "divider", 93 - }, 94 - { 95 - type: "context", 96 - elements: [ 97 - { 98 - type: "mrkdwn", 99 - text: `take by <@${user}> for \`${prettyPrintTime(take.elapsedTimeMs)}\` working on: *${take.notes}*`, 100 - }, 101 - ], 102 - }, 103 - ], 104 - }); 105 - 106 - await slackClient.chat.postMessage({ 107 - channel: process.env.SLACK_REVIEW_CHANNEL || "", 108 - text: ":video_camera: new take uploaded!", 109 - blocks: [ 110 - { 111 - type: "section", 112 - text: { 113 - type: "mrkdwn", 114 - text: `:video_camera: new take uploaded by <@${user}> for \`${prettyPrintTime(take.elapsedTimeMs)}\` working on: *${take.notes}*`, 115 - }, 116 - }, 117 - { 118 - type: "divider", 119 - }, 120 - { 121 - type: "video", 122 - video_url: `${process.env.API_URL}/api/video/?media=${take.media[0]}`, 123 - title_url: `${process.env.API_URL}/api/video/?media=${take.media[0]}`, 124 - title: { 125 - type: "plain_text", 126 - text: `${take.notes} by <@${user}> uploaded at ${generateSlackDate(takeUploadedAt)}`, 127 - }, 128 - thumbnail_url: `https://cachet.dunkirk.sh/users/${payload.user}/r`, 129 - alt_text: `takes from ${takeUploadedAt?.toLocaleString("en-CA", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false })} uploaded with the description: *${take.notes}*`, 130 - }, 131 - { 132 - type: "divider", 133 - }, 134 - { 135 - type: "actions", 136 - elements: [ 137 - { 138 - type: "static_select", 139 - placeholder: { 140 - type: "plain_text", 141 - text: "Select multiplier", 142 - }, 143 - options: [ 144 - { 145 - text: { 146 - type: "plain_text", 147 - text: "0.5x", 148 - }, 149 - value: "0.5", 150 - }, 151 - { 152 - text: { 153 - type: "plain_text", 154 - text: "1x", 155 - }, 156 - value: "1", 157 - }, 158 - { 159 - text: { 160 - type: "plain_text", 161 - text: "1.25x", 162 - }, 163 - value: "1.25", 164 - }, 165 - { 166 - text: { 167 - type: "plain_text", 168 - text: "1.5x", 169 - }, 170 - value: "1.5", 171 - }, 172 - { 173 - text: { 174 - type: "plain_text", 175 - text: "2x", 176 - }, 177 - value: "2", 178 - }, 179 - { 180 - text: { 181 - type: "plain_text", 182 - text: "3x", 183 - }, 184 - value: "3", 185 - }, 186 - ], 187 - action_id: "select_multiplier", 188 - }, 189 - { 190 - type: "button", 191 - text: { 192 - type: "plain_text", 193 - text: "approve", 194 - }, 195 - style: "primary", 196 - value: take.id, 197 - action_id: "approve", 198 - }, 199 - { 200 - type: "button", 201 - text: { 202 - type: "plain_text", 203 - text: "reject", 204 - }, 205 - style: "danger", 206 - value: take.id, 207 - action_id: "reject", 208 - }, 209 - ], 210 - }, 211 114 ], 212 115 }); 213 116 } catch (error) { 214 117 console.error("Error handling file message:", error); 215 118 await slackClient.chat.postMessage({ 216 119 channel: payload.channel, 217 - thread_ts: payload.thread_ts, 120 + thread_ts: payload.ts, 218 121 text: ":warning: there was an error processing your upload", 219 122 }); 220 123 ··· 222 125 extra: { 223 126 channel: payload.channel, 224 127 user: payload.user, 225 - thread_ts: payload.thread_ts, 128 + thread_ts: payload.ts, 226 129 }, 227 130 tags: { 228 131 type: "file_upload_error", 229 - }, 230 - }); 231 - } 232 - }); 233 - 234 - slackApp.action("select_multiplier", async () => {}); 235 - slackApp.action("dismiss_message", async ({ payload, context }) => { 236 - try { 237 - if (context.respond) 238 - await context.respond({ 239 - delete_original: true, 240 - }); 241 - } catch (error) { 242 - console.error("Error dismissing message:", error); 243 - Sentry.captureException(error, { 244 - extra: { 245 - payload, 246 - context, 247 - }, 248 - tags: { 249 - type: "dismiss_message_error", 250 - }, 251 - }); 252 - } 253 - }); 254 - 255 - slackApp.action("approve", async ({ payload, context }) => { 256 - try { 257 - const multiplier = Object.values(payload.state.values)[0] 258 - ?.select_multiplier?.selected_option?.value; 259 - 260 - // @ts-expect-error 261 - const takeId = payload.actions[0]?.value; 262 - 263 - const take = await db 264 - .select() 265 - .from(takesTable) 266 - .where(eq(takesTable.id, takeId)); 267 - if (take.length === 0) { 268 - return; 269 - } 270 - 271 - const takeToApprove = take[0]; 272 - if (!takeToApprove) return; 273 - 274 - if (!multiplier || Number.isNaN(Number(multiplier))) { 275 - await slackClient.chat.postEphemeral({ 276 - channel: process.env.SLACK_REVIEW_CHANNEL || "", 277 - user: payload.user.id, 278 - text: ":warning: please select a multiplier", 279 - blocks: [ 280 - { 281 - type: "actions", 282 - elements: [ 283 - { 284 - type: "button", 285 - text: { 286 - type: "plain_text", 287 - text: "⚠️ I'll select a multiplier", 288 - }, 289 - action_id: "dismiss_message", 290 - }, 291 - ], 292 - }, 293 - ], 294 - }); 295 - return; 296 - } 297 - 298 - await db 299 - .update(takesTable) 300 - .set({ 301 - multiplier: multiplier, 302 - }) 303 - .where(eq(takesTable.id, takeId)); 304 - 305 - await slackClient.chat.postMessage({ 306 - channel: payload.user.id, 307 - thread_ts: take[0]?.ts as string, 308 - text: `take approved with multiplier \`${multiplier}\` so you have earned *${Number((takeToApprove.elapsedTimeMs * Number(multiplier)) / 1000 / 3600).toFixed(1)} takes*!`, 309 - }); 310 - 311 - // delete the message from the review channel 312 - if (context.respond) 313 - await context.respond({ 314 - delete_original: true, 315 - }); 316 - } catch (error) { 317 - console.error("Error approving take:", error); 318 - 319 - await slackClient.chat.postEphemeral({ 320 - channel: process.env.SLACK_REVIEW_CHANNEL || "", 321 - user: payload.user.id, 322 - text: ":warning: there was an error approving the take", 323 - }); 324 - 325 - Sentry.captureException(error, { 326 - extra: { 327 - // @ts-expect-error 328 - take: payload.actions[0]?.value, 329 - userApproving: payload.user, 330 - }, 331 - tags: { 332 - type: "take_approve_error", 333 - }, 334 - }); 335 - } 336 - }); 337 - 338 - slackApp.action("reject", async ({ payload, context }) => { 339 - try { 340 - // @ts-expect-error 341 - const takeId = payload.actions[0]?.value; 342 - 343 - const take = await db 344 - .select() 345 - .from(takesTable) 346 - .where(eq(takesTable.id, takeId)); 347 - if (take.length === 0) { 348 - return; 349 - } 350 - await db 351 - .update(takesTable) 352 - .set({ 353 - multiplier: "0", 354 - }) 355 - .where(eq(takesTable.id, takeId)); 356 - 357 - await slackClient.chat.postMessage({ 358 - channel: payload.user.id, 359 - thread_ts: take[0]?.ts as string, 360 - text: "take rejected :(", 361 - }); 362 - 363 - // delete the message from the review channel 364 - if (context.respond) 365 - await context.respond({ 366 - delete_original: true, 367 - }); 368 - } catch (error) { 369 - console.error("Error rejecting take:", error); 370 - 371 - await slackClient.chat.postEphemeral({ 372 - channel: process.env.SLACK_REVIEW_CHANNEL || "", 373 - user: payload.user.id, 374 - text: ":warning: there was an error rejecting the take", 375 - }); 376 - 377 - Sentry.captureException(error, { 378 - extra: { 379 - // @ts-expect-error 380 - take: payload.actions[0]?.value, 381 - userRejecting: payload.user, 382 - }, 383 - tags: { 384 - type: "take_reject_error", 385 132 }, 386 133 }); 387 134 }
+2 -20
src/libs/config.ts
··· 1 1 // Configuration defaults and constants for the takes application 2 2 3 3 export const TakesConfig = { 4 - // Default takes session length in minutes (should be 90 for production) 5 - DEFAULT_SESSION_LENGTH: 90, 6 - 7 - // Maximum time in minutes that a takes session can be paused before automatic expiration 8 - MAX_PAUSE_DURATION: 45, 9 - 10 - // Maximum number of past takes to display in history 11 - MAX_HISTORY_ITEMS: 7, 12 - 13 - // Time thresholds for notifications (in minutes) 14 - NOTIFICATIONS: { 15 - // When to send a warning about low time remaining (minutes) 16 - LOW_TIME_WARNING: 5, 17 - 18 - // When to send a warning about pause expiration (minutes) 19 - PAUSE_EXPIRATION_WARNING: 5, 20 - 21 - // Frequency to check for notifications (milliseconds) 22 - CHECK_INTERVAL: 10 * 1000, // Every 10 seconds 23 - }, 4 + START_DATE: new Date("2025-04-18"), 5 + END_DATE: new Date("2025-05-31"), 24 6 }; 25 7 26 8 export default TakesConfig;
+29 -23
src/libs/schema.ts
··· 7 7 userId: text("user_id").notNull(), 8 8 ts: text("ts").notNull(), 9 9 elapsedTimeMs: integer("elapsed_time_ms").notNull().default(0), 10 - createdAt: integer("created_at") 11 - .$defaultFn(() => Math.floor(new Date().getTime() / 1000)) 10 + createdAt: text("created_at") 11 + .$defaultFn(() => new Date().toISOString()) 12 12 .notNull(), 13 13 media: text("media").notNull().default("[]"), // array of media urls 14 14 multiplier: text("multiplier").notNull().default("1.0"), ··· 18 18 export const users = pgTable("users", { 19 19 id: text("id").primaryKey(), 20 20 totalTakesTime: integer("total_takes_time").default(0), 21 + hackatimeKeys: text("hackatime_keys").notNull().default("[]"), 22 + projectName: text("project_name").notNull().default(""), 23 + projectDescription: text("project_description").notNull().default(""), 21 24 }); 22 25 23 26 export async function setupTriggers(pool: Pool) { ··· 25 28 CREATE INDEX IF NOT EXISTS idx_takes_user_id ON takes(user_id); 26 29 27 30 CREATE OR REPLACE FUNCTION update_user_total_time() 28 - RETURNS TRIGGER AS $$ 29 - BEGIN 30 - IF TG_OP = 'INSERT' THEN 31 - UPDATE users 32 - SET total_takes_time = COALESCE(total_takes_time, 0) + NEW.elapsed_time_ms 33 - WHERE id = NEW.user_id; 34 - ELSIF TG_OP = 'DELETE' THEN 35 - UPDATE users 36 - SET total_takes_time = COALESCE(total_takes_time, 0) - OLD.elapsed_time_ms 37 - WHERE id = OLD.user_id; 38 - ELSIF TG_OP = 'UPDATE' THEN 39 - UPDATE users 40 - SET total_takes_time = COALESCE(total_takes_time, 0) - OLD.elapsed_time_ms + NEW.elapsed_time_ms 41 - WHERE id = NEW.user_id; 42 - END IF; 31 + RETURNS TRIGGER AS $$ 32 + BEGIN 33 + IF TG_OP = 'INSERT' THEN 34 + UPDATE users 35 + SET total_takes_time = COALESCE(total_takes_time, 0) + NEW.elapsed_time_ms 36 + WHERE id = NEW.user_id; 37 + RETURN NEW; 38 + ELSIF TG_OP = 'DELETE' THEN 39 + UPDATE users 40 + SET total_takes_time = COALESCE(total_takes_time, 0) - OLD.elapsed_time_ms 41 + WHERE id = OLD.user_id; 42 + RETURN OLD; 43 + ELSIF TG_OP = 'UPDATE' THEN 44 + UPDATE users 45 + SET total_takes_time = COALESCE(total_takes_time, 0) - OLD.elapsed_time_ms + NEW.elapsed_time_ms 46 + WHERE id = NEW.user_id; 47 + RETURN NEW; 48 + END IF; 43 49 44 - EXCEPTION WHEN OTHERS THEN 45 - RAISE NOTICE 'Error updating user total time: %', SQLERRM; 46 - RETURN NULL; 50 + RETURN NULL; -- Default return for unexpected operations 47 51 48 - RETURN NEW; 49 - END; 50 - $$ LANGUAGE plpgsql; 52 + EXCEPTION WHEN OTHERS THEN 53 + RAISE NOTICE 'Error updating user total time: %', SQLERRM; 54 + RETURN NULL; 55 + END; 56 + $$ LANGUAGE plpgsql; 51 57 52 58 DROP TRIGGER IF EXISTS update_user_total_time_trigger ON takes; 53 59