-
-
Notifications
You must be signed in to change notification settings - Fork 88
Fix N+1 tickets #217
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Fix N+1 tickets #217
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1665,7 +1665,14 @@ class SupabaseService { | |
| final isAdmin = userProfile['role'] == 'admin'; | ||
|
|
||
| // Create base query | ||
| final query = _client.from('tickets').select('*').eq('team_id', teamId); | ||
| // Using Supabase relational joins here completely eliminates the N+1 query problem. | ||
| // Instead of looping and querying `_getUserInfo` for each ticket individually, | ||
| // we fetch the creator and assignee info in a single optimized database request. | ||
| final query = _client.from('tickets').select(''' | ||
| *, | ||
| creator:users!tickets_created_by_fkey(id, full_name, role), | ||
| assignee:users!tickets_assigned_to_fkey(id, full_name, role) | ||
| ''').eq('team_id', teamId); | ||
|
|
||
| // Filter by assignment if requested and user is not admin | ||
| if (filterByAssignment && !isAdmin) { | ||
|
|
@@ -1685,30 +1692,15 @@ class SupabaseService { | |
| query.eq('priority', filterByPriority); | ||
| } | ||
|
|
||
| // Execute the optimized query and order the results | ||
| final response = await query.order('created_at', ascending: false); | ||
|
|
||
| // Process the response to add creator and assignee info | ||
| // Process the response into the format expected by the app | ||
| final List<Map<String, dynamic>> processedTickets = []; | ||
| for (var ticket in response) { | ||
| final Map<String, dynamic> processedTicket = {...ticket}; | ||
|
|
||
| // Add creator info | ||
| if (ticket['created_by'] != null) { | ||
| final creatorInfo = await _getUserInfo(ticket['created_by']); | ||
| if (creatorInfo != null) { | ||
| processedTicket['creator'] = creatorInfo; | ||
| } | ||
| } | ||
|
|
||
| // Add assignee info | ||
| if (ticket['assigned_to'] != null) { | ||
| final assigneeInfo = await _getUserInfo(ticket['assigned_to']); | ||
| if (assigneeInfo != null) { | ||
| processedTicket['assignee'] = assigneeInfo; | ||
| } | ||
| } | ||
|
|
||
| processedTickets.add(processedTicket); | ||
| // The relational join already handles embedding the creator and assignee data. | ||
| // We ensure we retain compatibility with the original returned data structure. | ||
| processedTickets.add({...ticket}); | ||
| } | ||
|
|
||
| return processedTickets; | ||
|
|
@@ -1818,12 +1810,50 @@ class SupabaseService { | |
| }; | ||
| } | ||
|
|
||
| final createdTicket = Map<String, dynamic>.from(response[0]); | ||
| final ticketId = createdTicket['id']; | ||
|
|
||
| try { | ||
| final functionsResponse = await _client.functions.invoke( | ||
| 'create-github-issue', | ||
| body: { | ||
| 'title': title, | ||
| 'description': description, | ||
| 'category': category, | ||
| 'priority': priority, | ||
| 'ticketId': ticketId, | ||
| }, | ||
| ); | ||
|
|
||
| if (functionsResponse.status == 200) { | ||
| final responseData = functionsResponse.data; | ||
| if (responseData != null && responseData['success'] == true) { | ||
| final String? issueNumber = responseData['issueNumber']; | ||
| final String? issueUrl = responseData['issueUrl']; | ||
|
|
||
| if (issueNumber != null && issueUrl != null) { | ||
| await _client.from('tickets').update({ | ||
| 'github_issue_number': issueNumber, | ||
| 'github_issue_url': issueUrl, | ||
| }).eq('id', ticketId); | ||
|
|
||
| createdTicket['github_issue_number'] = issueNumber; | ||
| createdTicket['github_issue_url'] = issueUrl; | ||
| } | ||
| } | ||
| } else { | ||
| debugPrint('GitHub Edge Function returned status ${functionsResponse.status}'); | ||
| } | ||
| } catch (e) { | ||
| debugPrint('Failed to sync ticket to GitHub: $e'); | ||
| } | ||
|
Comment on lines
+1816
to
+1849
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: In Important behavior: if the function returns a non-2xx status code, Example try {
final res = await supabase.functions.invoke('hello');
print(res.status); // e.g. 200
print(res.data);
} on FunctionException catch (e) {
print(e.status); // non-2xx status code
print(e.details); // response body / error details
}Sources: Error handling logic is fundamentally flawed: the Per supabase_flutter documentation, 🤖 Prompt for AI Agents |
||
|
|
||
| // Refresh tickets | ||
| await getTickets(); | ||
|
|
||
| return { | ||
| 'success': true, | ||
| 'ticket': response[0], | ||
| 'ticket': createdTicket, | ||
| }; | ||
| } catch (e) { | ||
| debugPrint('Error creating ticket: $e'); | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,72 @@ | ||||||||||||||||||||||||||||
| import { serve } from "https://deno.land/std@0.168.0/http/server.ts" | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| const corsHeaders = { | ||||||||||||||||||||||||||||
| 'Access-Control-Allow-Origin': '*', | ||||||||||||||||||||||||||||
| 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| serve(async (req) => { | ||||||||||||||||||||||||||||
| // Handle CORS preflight | ||||||||||||||||||||||||||||
| if (req.method === 'OPTIONS') { | ||||||||||||||||||||||||||||
| return new Response('ok', { headers: corsHeaders }) | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||
| const { title, description, category, priority, ticketId } = await req.json() | ||||||||||||||||||||||||||||
|
Comment on lines
+14
to
+15
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add input validation for required fields. The function destructures inputs but doesn't validate that required fields ( 🛡️ Proposed fix to add validation try {
const { title, description, category, priority, ticketId } = await req.json()
+
+ if (!title || !category || !priority || !ticketId) {
+ return new Response(
+ JSON.stringify({ success: false, error: 'Missing required fields: title, category, priority, ticketId' }),
+ { headers: { ...corsHeaders, "Content-Type": "application/json" }, status: 400 }
+ )
+ }
const githubToken = Deno.env.get('GITHUB_ACCESS_TOKEN')📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| const githubToken = Deno.env.get('GITHUB_ACCESS_TOKEN') | ||||||||||||||||||||||||||||
| const repoOwner = Deno.env.get('GITHUB_REPO_OWNER') | ||||||||||||||||||||||||||||
| const repoName = Deno.env.get('GITHUB_REPO_NAME') | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| if (!githubToken || !repoOwner || !repoName) { | ||||||||||||||||||||||||||||
| throw new Error("GitHub configuration is missing in environment variables.") | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| const githubApiUrl = `https://api.github.com/repos/${repoOwner}/${repoName}/issues` | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| const response = await fetch(githubApiUrl, { | ||||||||||||||||||||||||||||
| method: "POST", | ||||||||||||||||||||||||||||
| headers: { | ||||||||||||||||||||||||||||
| "Accept": "application/vnd.github+json", | ||||||||||||||||||||||||||||
| "Authorization": `Bearer ${githubToken}`, | ||||||||||||||||||||||||||||
| "X-GitHub-Api-Version": "2022-11-28", | ||||||||||||||||||||||||||||
| "Content-Type": "application/json" | ||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||
| body: JSON.stringify({ | ||||||||||||||||||||||||||||
| title: `[${category}] ${title}`, | ||||||||||||||||||||||||||||
| body: `**Ticket ID:** ${ticketId}\n**Priority:** ${priority}\n\n${description || 'No description provided.'}`, | ||||||||||||||||||||||||||||
| labels: [category?.toLowerCase() || 'bug', priority?.toLowerCase() || 'medium'] | ||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| if (!response.ok) { | ||||||||||||||||||||||||||||
| const errorText = await response.text() | ||||||||||||||||||||||||||||
| console.error(`GitHub API Error: ${response.status} ${errorText}`) | ||||||||||||||||||||||||||||
| throw new Error(`GitHub API failed with status ${response.status}`) | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| const data = await response.json() | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| return new Response( | ||||||||||||||||||||||||||||
| JSON.stringify({ | ||||||||||||||||||||||||||||
| success: true, | ||||||||||||||||||||||||||||
| issueNumber: data.number.toString(), | ||||||||||||||||||||||||||||
| issueUrl: data.html_url | ||||||||||||||||||||||||||||
| }), | ||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||
| headers: { ...corsHeaders, "Content-Type": "application/json" }, | ||||||||||||||||||||||||||||
| status: 200, | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||||||||
| const errorMessage = error instanceof Error ? error.message : String(error) | ||||||||||||||||||||||||||||
| console.error("Edge Function Error:", errorMessage) | ||||||||||||||||||||||||||||
| return new Response( | ||||||||||||||||||||||||||||
| JSON.stringify({ success: false, error: errorMessage }), | ||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||
| headers: { ...corsHeaders, "Content-Type": "application/json" }, | ||||||||||||||||||||||||||||
| status: 500, // Return 500 but handled gracefully by the client | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| -- Migration to add GitHub issue tracking to tickets | ||
| ALTER TABLE tickets | ||
| ADD COLUMN github_issue_number TEXT, | ||
| ADD COLUMN github_issue_url TEXT; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick | 🔵 Trivial
Consider simplifying the processing loop.
Since the relational join already embeds creator and assignee data, the loop that spreads each ticket into a new map is redundant. You could simplify this to a direct type cast.
♻️ Optional simplification
// Execute the optimized query and order the results final response = await query.order('created_at', ascending: false); - // Process the response into the format expected by the app - final List<Map<String, dynamic>> processedTickets = []; - for (var ticket in response) { - // The relational join already handles embedding the creator and assignee data. - // We ensure we retain compatibility with the original returned data structure. - processedTickets.add({...ticket}); - } - - return processedTickets; + return List<Map<String, dynamic>>.from(response);📝 Committable suggestion
🤖 Prompt for AI Agents