11
22'use server' ;
33
4- import type { Project , ProjectMember , ProjectMemberRole , Task , TaskStatus , Tag , Document as ProjectDocumentType , Announcement as ProjectAnnouncement , UserGithubOAuthToken } from '@/types' ;
4+ import type { Project , ProjectMember , ProjectMemberRole , Task , TaskStatus , Tag , Document as ProjectDocumentType , Announcement as ProjectAnnouncement , UserGithubOAuthToken , GithubRepoContentItem } from '@/types' ;
55import {
66 getProjectByUuid as dbGetProjectByUuid ,
77 getUserByUuid as dbGetUserByUuid ,
@@ -13,7 +13,6 @@ import {
1313 getProjectMemberRole as dbGetProjectMemberRole ,
1414 createTask as dbCreateTask ,
1515 getTasksForProject as dbGetTasksForProject ,
16- updateTaskStatus as dbUpdateTaskStatus ,
1716 updateTask as dbUpdateTask ,
1817 deleteTask as dbDeleteTask ,
1918 getProjectTags as dbGetProjectTags ,
@@ -37,7 +36,7 @@ import { z } from 'zod';
3736import { auth } from '@/lib/authEdge' ;
3837import { Octokit } from 'octokit' ;
3938import { Buffer } from 'buffer' ;
40- import { getInstallationOctokit , getAppAuthOctokit } from '@/lib/githubAppClient' ;
39+ import { getAppAuthOctokit , getInstallationOctokit } from '@/lib/githubAppClient' ;
4140
4241
4342export async function fetchProjectAction ( uuid : string | undefined ) : Promise < Project | null > {
@@ -321,65 +320,6 @@ export async function fetchTasksAction(projectUuid: string | undefined): Promise
321320 }
322321}
323322
324- const UpdateTaskStatusSchema = z . object ( {
325- taskUuid : z . string ( ) . uuid ( "Invalid task UUID." ) ,
326- projectUuid : z . string ( ) . uuid ( "Invalid project UUID." ) ,
327- status : z . enum ( [ 'To Do' , 'In Progress' , 'Done' , 'Archived' ] as [ TaskStatus , ...TaskStatus [ ] ] ) ,
328- } ) ;
329-
330- export interface UpdateTaskStatusFormState {
331- message ?: string ;
332- error ?: string ;
333- updatedTask ?: Task ;
334- }
335-
336- export async function updateTaskStatusAction ( prevState : UpdateTaskStatusFormState , formData : FormData ) : Promise < UpdateTaskStatusFormState > {
337- const session = await auth ( ) ;
338- if ( ! session ?. user ?. uuid ) {
339- console . error ( "[updateTaskStatusAction] Authentication required. No session user UUID." ) ;
340- return { error : "Authentication required." } ;
341- }
342- console . log ( "[updateTaskStatusAction] Authenticated user for permission check:" , session . user . uuid ) ;
343-
344- const validatedFields = UpdateTaskStatusSchema . safeParse ( {
345- taskUuid : formData . get ( 'taskUuid' ) ,
346- projectUuid : formData . get ( 'projectUuid' ) ,
347- status : formData . get ( 'status' ) ,
348- } ) ;
349-
350- if ( ! validatedFields . success ) {
351- return { error : "Invalid input: " + JSON . stringify ( validatedFields . error . flatten ( ) . fieldErrors ) } ;
352- }
353- const { taskUuid, projectUuid, status } = validatedFields . data ;
354-
355- try {
356- const userRole = await dbGetProjectMemberRole ( projectUuid , session . user . uuid ) ;
357- console . log ( `[updateTaskStatusAction] User role check for project ${ projectUuid } (user ${ session . user . uuid } ): ${ userRole } ` ) ;
358- if ( ! userRole ) {
359- return { error : `You are not a member of this project. Your role: ${ userRole || 'not a member' } . UUID: ${ session . user . uuid } ` } ;
360- }
361-
362- const updatedTask = await dbUpdateTaskStatus ( taskUuid , status ) ;
363- if ( ! updatedTask ) {
364- return { error : "Failed to update task status." } ;
365- }
366- return { message : "Task status updated successfully!" , updatedTask } ;
367- } catch ( error : any ) {
368- console . error ( "Error updating task status:" , error ) ;
369- return { error : error . message || "An unexpected error occurred." } ;
370- }
371- }
372-
373- const UpdateTaskSchema = z . object ( {
374- taskUuid : z . string ( ) . uuid ( "Invalid task UUID." ) ,
375- projectUuid : z . string ( ) . uuid ( "Invalid project UUID." ) ,
376- title : z . string ( ) . min ( 1 , "Title is required." ) . max ( 255 ) ,
377- description : z . string ( ) . optional ( ) ,
378- todoListMarkdown : z . string ( ) . optional ( ) . default ( '' ) ,
379- status : z . enum ( [ 'To Do' , 'In Progress' , 'Done' , 'Archived' ] as [ TaskStatus , ...TaskStatus [ ] ] ) ,
380- assigneeUuid : z . string ( ) . uuid ( "Invalid Assignee UUID format." ) . optional ( ) . or ( z . literal ( '' ) ) ,
381- tagsString : z . string ( ) . optional ( ) ,
382- } ) ;
383323
384324export interface UpdateTaskFormState {
385325 message ?: string ;
@@ -396,48 +336,74 @@ export async function updateTaskAction(prevState: UpdateTaskFormState, formData:
396336 }
397337 console . log ( "[updateTaskAction] Authenticated user for permission check:" , session . user . uuid ) ;
398338
339+ const UpdateTaskSchema = z . object ( {
340+ taskUuid : z . string ( ) . uuid ( "Invalid task UUID." ) ,
341+ projectUuid : z . string ( ) . uuid ( "Invalid project UUID." ) ,
342+ title : z . string ( ) . min ( 1 , "Title is required." ) . max ( 255 ) . optional ( ) ,
343+ description : z . string ( ) . optional ( ) ,
344+ todoListMarkdown : z . string ( ) . optional ( ) . default ( '' ) ,
345+ status : z . enum ( [ 'To Do' , 'In Progress' , 'Done' , 'Archived' ] as [ TaskStatus , ...TaskStatus [ ] ] ) . optional ( ) ,
346+ assigneeUuid : z . string ( ) . uuid ( "Invalid Assignee UUID format." ) . optional ( ) . or ( z . literal ( '' ) ) ,
347+ tagsString : z . string ( ) . optional ( ) ,
348+ } ) ;
349+
399350
400351 const validatedFields = UpdateTaskSchema . safeParse ( {
401352 taskUuid : formData . get ( 'taskUuid' ) ,
402353 projectUuid : formData . get ( 'projectUuid' ) ,
403- title : formData . get ( 'title' ) ,
404- description : formData . get ( 'description' ) || '' ,
405- todoListMarkdown : formData . get ( 'todoListMarkdown' ) || '' ,
406- status : formData . get ( 'status' ) ,
407- assigneeUuid : formData . get ( 'assigneeUuid' ) || '' ,
408- tagsString : formData . get ( 'tagsString' ) ,
354+ title : formData . has ( 'title' ) ? formData . get ( 'title' ) : undefined ,
355+ description : formData . has ( 'description' ) ? ( formData . get ( 'description' ) || '' ) : undefined ,
356+ todoListMarkdown : formData . has ( 'todoListMarkdown' ) ? ( formData . get ( 'todoListMarkdown' ) || '' ) : undefined ,
357+ status : formData . has ( 'status' ) ? formData . get ( 'status' ) : undefined ,
358+ assigneeUuid : formData . has ( 'assigneeUuid' ) ? ( formData . get ( 'assigneeUuid' ) || '' ) : undefined ,
359+ tagsString : formData . has ( 'tagsString' ) ? formData . get ( 'tagsString' ) : undefined ,
409360 } ) ;
410361
411362 if ( ! validatedFields . success ) {
412363 console . error ( "[updateTaskAction] Validation failed:" , validatedFields . error . flatten ( ) . fieldErrors ) ;
413364 return { error : "Invalid input." , fieldErrors : validatedFields . error . flatten ( ) . fieldErrors } ;
414365 }
415366
416- const { taskUuid, projectUuid, title , description , todoListMarkdown , status , assigneeUuid : rawAssigneeUuid , tagsString } = validatedFields . data ;
367+ const { taskUuid, projectUuid, ... taskUpdateData } = validatedFields . data ;
417368
418- let finalAssigneeUuid : string | null = null ;
419- if ( rawAssigneeUuid && rawAssigneeUuid !== '' && rawAssigneeUuid !== '__UNASSIGNED__' ) {
420- finalAssigneeUuid = rawAssigneeUuid ;
369+ let finalAssigneeUuid : string | null | undefined = taskUpdateData . assigneeUuid ;
370+ if ( taskUpdateData . assigneeUuid === '__UNASSIGNED__' ) {
371+ finalAssigneeUuid = null ;
372+ } else if ( taskUpdateData . assigneeUuid === '' ) {
373+ finalAssigneeUuid = undefined ; // To not update if not provided
421374 }
422375
376+
423377 try {
424378 const userRole = await dbGetProjectMemberRole ( projectUuid , session . user . uuid ) ;
425379 console . log ( `[updateTaskAction] User role check for project ${ projectUuid } (user ${ session . user . uuid } ): ${ userRole } ` ) ;
426- if ( ! userRole || ! [ 'owner' , 'co-owner' , 'editor' ] . includes ( userRole ) ) {
427- return { error : `You do not have permission to update tasks in this project. Your role: ${ userRole || 'not a member' } . UUID: ${ session . user . uuid } ` } ;
380+
381+ const canUpdateStatusOnly = userRole && ! [ 'owner' , 'co-owner' , 'editor' ] . includes ( userRole ) ;
382+ const canUpdateFull = userRole && [ 'owner' , 'co-owner' , 'editor' ] . includes ( userRole ) ;
383+
384+ if ( ! canUpdateFull && ! canUpdateStatusOnly ) {
385+ return { error : `You do not have permission to update tasks in this project. Your role: ${ userRole || 'not a member' } . UUID: ${ session . user . uuid } ` } ;
386+ }
387+
388+ const dataToUpdate : Partial < Omit < Task , 'uuid' | 'projectUuid' | 'createdAt' | 'updatedAt' | 'tags' | 'assigneeName' > > & { tagsString ?: string } = { } ;
389+
390+ if ( canUpdateFull ) {
391+ if ( taskUpdateData . title !== undefined ) dataToUpdate . title = taskUpdateData . title ;
392+ if ( taskUpdateData . description !== undefined ) dataToUpdate . description = taskUpdateData . description || undefined ;
393+ if ( taskUpdateData . todoListMarkdown !== undefined ) dataToUpdate . todoListMarkdown = taskUpdateData . todoListMarkdown || undefined ;
394+ if ( finalAssigneeUuid !== undefined ) dataToUpdate . assigneeUuid = finalAssigneeUuid ; // Handles null for unassign
395+ if ( taskUpdateData . tagsString !== undefined ) dataToUpdate . tagsString = taskUpdateData . tagsString || undefined ;
428396 }
429397
430- const taskData : Partial < Omit < Task , 'uuid' | 'projectUuid' | 'createdAt' | 'updatedAt' | 'tags' | 'assigneeName' > > & { tagsString ?: string } = { } ;
398+ // Status can always be updated by any member
399+ if ( taskUpdateData . status !== undefined ) dataToUpdate . status = taskUpdateData . status ;
431400
432- if ( formData . has ( 'title' ) ) taskData . title = title ;
433- if ( formData . has ( 'description' ) ) taskData . description = description || undefined ;
434- if ( formData . has ( 'todoListMarkdown' ) ) taskData . todoListMarkdown = todoListMarkdown || undefined ;
435- if ( formData . has ( 'status' ) ) taskData . status = status ;
436- if ( formData . has ( 'assigneeUuid' ) ) taskData . assigneeUuid = finalAssigneeUuid ;
437- if ( formData . has ( 'tagsString' ) ) taskData . tagsString = tagsString || undefined ;
438401
402+ if ( Object . keys ( dataToUpdate ) . length === 0 ) {
403+ return { message : "No changes to update." , updatedTask : await dbGetTaskByUuid ( taskUuid ) || undefined } ;
404+ }
439405
440- const updatedTask = await dbUpdateTask ( taskUuid , taskData ) ;
406+ const updatedTask = await dbUpdateTask ( taskUuid , dataToUpdate ) ;
441407 if ( ! updatedTask ) {
442408 return { error : "Failed to update task." } ;
443409 }
@@ -564,8 +530,8 @@ async function updateReadmeOnGithub(octokit: Octokit, owner: string, repo: strin
564530 message : 'Update README.md from FlowUp' ,
565531 content : Buffer . from ( content ) . toString ( 'base64' ) ,
566532 committer : {
567- name : 'FlowUp Bot' ,
568- email : 'bot@flowup.app' ,
533+ name : 'FlowUp Bot' , // Consider making this configurable or using the user's GitHub name
534+ email : 'bot@flowup.app' , // Or a no-reply / user's email
569535 } ,
570536 } ;
571537 if ( existingSha ) {
@@ -642,7 +608,9 @@ export async function saveProjectReadmeAction(prevState: SaveProjectReadmeFormSt
642608 repo,
643609 path : 'README.md' ,
644610 } ) ;
611+ // @ts -ignore // Octokit types can be tricky with union types for content
645612 if ( 'sha' in readmeData && readmeData . type === 'file' ) {
613+ // @ts -ignore
646614 existingSha = readmeData . sha ;
647615 }
648616 } catch ( error : any ) {
@@ -1155,20 +1123,20 @@ export async function linkProjectToGithubAction(
11551123 }
11561124
11571125 const projectUuid = formData . get ( 'projectUuid' ) as string ;
1158- const flowUpProjectNameValue = formData . get ( 'flowUpProjectName' ) ;
1159- const githubRepoNameValue = formData . get ( 'githubRepoName' ) ;
1126+ const flowUpProjectNameValue = formData . get ( 'flowUpProjectName' ) as string | null ;
1127+ const githubRepoNameValue = formData . get ( 'githubRepoName' ) as string | null ;
11601128 const useDefaultRepoName = formData . get ( 'useDefaultRepoName' ) === 'true' ;
11611129
11621130 let nameForRepoCreation : string ;
11631131
11641132 if ( useDefaultRepoName ) {
1165- if ( typeof flowUpProjectNameValue === 'string' && flowUpProjectNameValue . trim ( ) !== '' ) {
1133+ if ( flowUpProjectNameValue && flowUpProjectNameValue . trim ( ) !== '' ) {
11661134 nameForRepoCreation = `FlowUp - ${ flowUpProjectNameValue } ` ;
11671135 } else {
11681136 return { error : "FlowUp Project Name is required to generate the default repository name." } ;
11691137 }
11701138 } else {
1171- if ( typeof githubRepoNameValue === 'string' && githubRepoNameValue . trim ( ) !== '' ) {
1139+ if ( githubRepoNameValue && githubRepoNameValue . trim ( ) !== '' ) {
11721140 nameForRepoCreation = githubRepoNameValue ;
11731141 } else {
11741142 return { error : "Custom repository name cannot be empty." } ;
@@ -1208,10 +1176,11 @@ export async function linkProjectToGithubAction(
12081176 name : repoSlug ,
12091177 private : repoIsPrivate ,
12101178 description : `Repository for FlowUp project: ${ projectFromDb . name } ` ,
1211- auto_init : true ,
1179+ auto_init : true , // Initialize with a README to avoid empty repo issues
12121180 } ) ;
12131181 console . log ( `Successfully created repository: ${ createdRepo . data . html_url } ` ) ;
12141182
1183+ // Sync FlowUp README to GitHub if content exists
12151184 if ( projectFromDb . readmeContent && projectFromDb . readmeContent . trim ( ) !== '' ) {
12161185 let existingReadmeSha : string | undefined = undefined ;
12171186 try {
@@ -1220,7 +1189,9 @@ export async function linkProjectToGithubAction(
12201189 repo : createdRepo . data . name ,
12211190 path : 'README.md' ,
12221191 } ) ;
1192+ // @ts -ignore
12231193 if ( 'sha' in readmeData && readmeData . type === 'file' ) {
1194+ // @ts -ignore
12241195 existingReadmeSha = readmeData . sha ;
12251196 }
12261197 } catch ( getContentError : any ) {
@@ -1244,7 +1215,7 @@ export async function linkProjectToGithubAction(
12441215 }
12451216
12461217 const repoUrl = createdRepo . data . html_url ;
1247- const actualRepoName = createdRepo . data . full_name ;
1218+ const actualRepoName = createdRepo . data . full_name ; // e.g., "username/repo-name"
12481219
12491220 const updatedProject = await dbUpdateProjectGithubRepo ( projectUuid , repoUrl , actualRepoName ) ;
12501221
@@ -1260,22 +1231,79 @@ export async function linkProjectToGithubAction(
12601231 }
12611232}
12621233
1263- async function fetchUserGithubAccessDetailsAction ( userUuid : string ) : Promise < {
1264- oauthToken : UserGithubOAuthToken | null ;
1265- installation : UserGithubInstallation | null ;
1266- } > {
1234+ // GitHub File Management Actions
1235+ export async function getRepoContentsAction ( projectUuid : string , path : string = '' ) : Promise < GithubRepoContentItem [ ] | { error : string } > {
1236+ const session = await auth ( ) ;
1237+ if ( ! session ?. user ?. uuid ) return { error : "Authentication required." } ;
1238+
1239+ const project = await dbGetProjectByUuid ( projectUuid ) ;
1240+ if ( ! project || ! project . githubRepoName ) {
1241+ return { error : "Project not found or not linked to GitHub." } ;
1242+ }
1243+
1244+ const oauthToken = await dbGetUserGithubOAuthToken ( session . user . uuid ) ;
1245+ if ( ! oauthToken || ! oauthToken . accessToken ) {
1246+ return { error : "GitHub account not linked or token missing." } ;
1247+ }
1248+
1249+ const [ owner , repo ] = project . githubRepoName . split ( '/' ) ;
1250+ if ( ! owner || ! repo ) return { error : "Invalid GitHub repository name format." } ;
1251+
1252+ const octokit = new Octokit ( { auth : oauthToken . accessToken } ) ;
1253+
1254+ try {
1255+ const { data } = await octokit . rest . repos . getContent ( {
1256+ owner,
1257+ repo,
1258+ path,
1259+ } ) ;
1260+ if ( Array . isArray ( data ) ) {
1261+ return data as GithubRepoContentItem [ ] ;
1262+ }
1263+ // If data is a single file object (when path points to a file)
1264+ return [ data as GithubRepoContentItem ] ;
1265+ } catch ( error : any ) {
1266+ console . error ( `[getRepoContentsAction] Error fetching content for ${ owner } /${ repo } /${ path } :` , error ) ;
1267+ return { error : error . message || "Failed to fetch repository contents." } ;
1268+ }
1269+ }
1270+
1271+ export async function getFileContentAction ( projectUuid : string , filePath : string ) : Promise < { content : string ; sha : string } | { error : string } > {
12671272 const session = await auth ( ) ;
1268- if ( ! session ?. user ?. uuid || session . user . uuid !== userUuid ) {
1269- console . error ( "[fetchUserGithubAccessDetailsAction] Authentication mismatch or missing session." ) ;
1270- return { oauthToken : null , installation : null } ;
1273+ if ( ! session ?. user ?. uuid ) return { error : "Authentication required." } ;
1274+
1275+ const project = await dbGetProjectByUuid ( projectUuid ) ;
1276+ if ( ! project || ! project . githubRepoName ) {
1277+ return { error : "Project not found or not linked to GitHub." } ;
1278+ }
1279+
1280+ const oauthToken = await dbGetUserGithubOAuthToken ( session . user . uuid ) ;
1281+ if ( ! oauthToken || ! oauthToken . accessToken ) {
1282+ return { error : "GitHub account not linked or token missing." } ;
12711283 }
1284+
1285+ const [ owner , repo ] = project . githubRepoName . split ( '/' ) ;
1286+ if ( ! owner || ! repo ) return { error : "Invalid GitHub repository name format." } ;
1287+
1288+ const octokit = new Octokit ( { auth : oauthToken . accessToken } ) ;
1289+
12721290 try {
1273- const oauthToken = await dbGetUserGithubOAuthToken ( userUuid ) ;
1274- return { oauthToken, installation : null } ;
1275- } catch ( error ) {
1276- console . error ( "[fetchUserGithubAccessDetailsAction] Error fetching GitHub access details:" , error ) ;
1277- return { oauthToken : null , installation : null } ;
1291+ const { data } = await octokit . rest . repos . getContent ( {
1292+ owner,
1293+ repo,
1294+ path : filePath ,
1295+ } ) ;
1296+
1297+ // @ts -ignore - Octokit's type for data can be an array or object
1298+ if ( data . type !== 'file' || typeof data . content !== 'string' || typeof data . sha !== 'string' ) {
1299+ return { error : "Path does not point to a valid file or content is missing." } ;
1300+ }
1301+ // @ts -ignore
1302+ const content = Buffer . from ( data . content , data . encoding as BufferEncoding || 'base64' ) . toString ( 'utf8' ) ;
1303+ // @ts -ignore
1304+ return { content, sha : data . sha } ;
1305+ } catch ( error : any ) {
1306+ console . error ( `[getFileContentAction] Error fetching file content for ${ owner } /${ repo } /${ filePath } :` , error ) ;
1307+ return { error : error . message || "Failed to fetch file content." } ;
12781308 }
12791309}
1280-
1281-
0 commit comments