diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 000affd..77fbae1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,10 +11,6 @@ jobs: build: runs-on: ubuntu-latest - env: - PUBLIC_SUPABASE_URL: ${{ secrets.PUBLIC_SUPABASE_URL }} - PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.PUBLIC_SUPABASE_ANON_KEY }} - steps: - name: Check out code uses: actions/checkout@v4 @@ -27,6 +23,9 @@ jobs: fi - name: Create .env file + env: + PUBLIC_SUPABASE_URL: ${{ secrets.PUBLIC_SUPABASE_URL }} + PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.PUBLIC_SUPABASE_ANON_KEY }} run: | echo "PUBLIC_SUPABASE_URL=${{ secrets.PUBLIC_SUPABASE_URL }}" >> .env echo "PUBLIC_SUPABASE_ANON_KEY=${{ secrets.PUBLIC_SUPABASE_ANON_KEY }}" >> .env diff --git a/Brief_Docs/Project Brief.txt b/Brief_Docs/Project Brief.md similarity index 50% rename from Brief_Docs/Project Brief.txt rename to Brief_Docs/Project Brief.md index d339329..173cf3d 100644 --- a/Brief_Docs/Project Brief.txt +++ b/Brief_Docs/Project Brief.md @@ -1,88 +1,84 @@ -Objective -One of the LIFT programme objectives is a thought leadership output focused on gathering all the insight from the best practice happening across our knowledge economy sectors into a digital resource that can support making these industries more inclusive and accessible. +# Project Brief -Since there are a vast number of toolkits which are broad reaching and comprehensive (including the launch of the GLA toolkits) � LIFT has decided to zoom in and focus our research into supporting neurodivergent people which is of pertinent interest and priority across our boroughs. +## Objective -Alot of the resources currently available are pdf and document formatted, often very compliance formatted hence not impacting the day-to-day workplace culture and environment. Whilst larger companies have resources available to dedicate whole teams to diversity and inclusion, SME�s don�t have such things available. LIFT therefore wants to tap into our access to larger businesses' resources and make it accessible to SME�s by digitising the work and making it interactive, palatable and useful day-to-day. +One of the LIFT programme objectives is a thought leadership output focused on gathering all the insight from the best practice happening across our knowledge economy sectors into a digital resource that can support making these industries more inclusive and accessible. -In focusing in on supporting neurodivergent people at on-boarding and development stages, we hope the resource can be used and useful to all employees � by generally making a more inclusive and adjusted workplace. +Since there are a vast number of toolkits which are broad reaching and comprehensive (including the launch of the GLA toolkits). LIFT has decided to zoom in and focus our research into supporting neurodivergent people which is of pertinent interest and priority across our boroughs. -Outline/brief -To develop a digital version of Islington Council�s existing workplace passport, that is accessible to a range of local employers, and provides a more user-friendly format than the existing pdf document. This should take the form of an online form, that provides the user with a downloadable and shareable document once complete. It should be simple, intuitive and fairly self-explanatory. It should also be easy to communicate across changing roles, workplaces and managers. +Alot of the resources currently available are pdf and document formatted, often very compliance formatted hence not impacting the day-to-day workplace culture and environment. Whilst larger companies have resources available to dedicate whole teams to diversity and inclusion, SMEs dont have such things available. LIFT therefore wants to tap into our access to larger businesses' resources and make it accessible to SMEs by digitising the work and making it interactive, palatable and useful day-to-day. -The user should have a profile and account that they can log into which is secure, and that allows them to review the information on the passport on a continuous basis. They should be able to update the passport as and when is necessary, including when their needs or circumstances change, and also when moving roles or when their line management changes. +In focusing in on supporting neurodivergent people at on-boarding and development stages, we hope the resource can be used and useful to all employees by generally making a more inclusive and adjusted workplace. -While we see this as a tool that employers would encourage new staff to use, the completed passport should be owned by the employee, not the employer. - -Main Function and Deliverables -User journey: - -1. Landing page that has a login[HM1][HM2] -2. Once logged in, the user sees a dashboard that has tiles for the following categories: -a. Profile -b. Wellness at work -c. My role as a parent or carer -d. My religion or beliefs -e. My experience as someone with a disability or long-term condition -f. Workplace adjustments and support -g. Additional information and resources +## Outline & Brief -Dashboard should look similar to the LIFT opportunities portal in appearance: +To develop a digital version of Islington Councils existing workplace passport, that is accessible to a range of local employers, and provides a more user-friendly format than the existing pdf document. This should take the form of an online form, that provides the user with a downloadable and shareable document once complete. It should be simple, intuitive and fairly self-explanatory. It should also be easy to communicate across changing roles, workplaces and managers. +The user should have a profile and account that they can log into which is secure, and that allows them to review the information on the passport on a continuous basis. They should be able to update the passport as and when is necessary, including when their needs or circumstances change, and also when moving roles or when their line management changes. -3. Profile should include basic account details: name, pronouns, job title, line manager, employer, and options to edit these. -4. Categories b-e include the questions as currently exist in the workplace passport, i.e. when the user clicks onto �wellness at work�, they are presented with the list of questions as they currently appear in the Beacons page +While we see this as a tool that employers would encourage new staff to use, the completed passport should be owned by the employee, not the employer. -5. When the user clicks on the question, a box pops up which allows them to enter their answer as free text. +## Main Function & Deliverables + +### User Journey + +1. Landing page that has a login +2. Once logged in, the user sees a dashboard that has tiles for the following categories (Dashboard should look similar to the LIFT opportunities portal in appearance): + a. Profile + b. Wellness at work + c. My role as a parent or carer + d. My religion or beliefs + e. My experience as someone with a disability or long-term condition + f. Workplace adjustments and support + g. Additional information and resources +3. Profile should include basic account details: name, pronouns, job title, line manager, employer, and options to edit these. +4. Categories b-e include the questions as currently exist in the workplace passport, i.e. when the user clicks onto wellness at work, they are presented with the list of questions as they currently appear in the Beacons page +5. When the user clicks on the question, a box pops up which allows them to enter their answer as free text. 6. It is not mandatory for the user to complete each question. 7. Following free text answer to be given an option of follow up actions, to include: -a. physical sensory environment adjustment -b. equipment requirements -c. time travel adjustment -d. additional resource -e. work style/ work pattern adjustment -f. 'Other' > additional free form text -8. When the user assigns an action, it should populate a line in the �workplace adjustments and support� section, which is a separate category on the dashboard. (we might need to discuss this bit further) -9. Workplace adjustments and support section should bank all the different actions assigned, and give the user the option to add additional lines. -10. Additional information and resources section should have links to further resources � we will provide these. + a. physical sensory environment adjustment + b. equipment requirements + c. time travel adjustment + d. additional resource + e. work style/ work pattern adjustment + f. 'Other' > additional free form text +8. When the user assigns an action, it should populate a line in the workplace adjustments and support section, which is a separate category on the dashboard. (we might need to discuss this bit further) +9. Workplace adjustments and support section should bank all the different actions assigned, and give the user the option to add additional lines. +10. Additional information and resources section should have links to further resources we will provide these. 11. The user should be able to share the completed passport questions and workplace adjustments with their manager. This should be sent to the manager by email from the app. They should also be able to download a complete summary of their responses for their own reference. (again we might need to discuss this further) -12. We should include some basic instructions on how to complete the tool somewhere on the dashboard page � or a help icon? +12. We should include some basic instructions on how to complete the tool somewhere on the dashboard page or a help icon? 13. Can there be some sort of audit trail of when the passport was created, reviewed, updated, and shared? -Timeline +### Timeline - Final sprint to completed in 12 developer days / 2 weeks - 2nd half of May - Need to do user testing after week 1 of second sprint - to get feedback +## User profile -User profile To be used by managers and employers to help on-board and track progress, support and adjust needs of neurodivergent employees. Neurodivergent and wider employees to communicate their reasonable workplace adjustments, changes in circumstances and needs to their line managers. To positively impact the workplace environment by establishing a culture of inclusivity, communication and support -User case 1; someone just starting a new job -A person who is neurodivergent who needs adjustments in lighting and sound levels in work environment. They also are not comfortable disclosing their neurodiversity face to face. At induction the employer would explain that they are subscribed to a service where they can track their feelings about their work environment and contribute to a better workplace for everyone by sharing how they work best, and what optimum conditions would need to be in place. - - -User case 2; someone new line manager -User 1 has had their line manager changed. They no longer want to share physical adjustments they need but do want to alert them about social queues, how they like to be communicated with in the mornings and that they don�t like to speak at group meetings. They should be able to login, update info on new line manager, change what they can make public or not, and alert their new supervisor in an email. They should be able to add free form text to the email to intro the reason for the email, and add if there any actions or if its just something to be aware of. - -User case 3; new job existing login -Similar to user 2 � they would be able to review on their dashboard what they would like to update and make public. They would update their workplace details and manager contacts on the dashboard. They would then be able to email an invitation to their manager to subscribe to and accept email alerts. +### User case 1; someone just starting a new job +A person who is neurodivergent who needs adjustments in lighting and sound levels in work environment. They also are not comfortable disclosing their neurodiversity face to face. At induction the employer would explain that they are subscribed to a service where they can track their feelings about their work environment and contribute to a better workplace for everyone by sharing how they work best, and what optimum conditions would need to be in place. -Compliance - -- Touch base with Digital Services � DPIA will change +### User case 2; someone new line manager -- DPIA completion +User 1 has had their line manager changed. They no longer want to share physical adjustments they need but do want to alert them about social queues, how they like to be communicated with in the mornings and that they dont like to speak at group meetings. They should be able to login, update info on new line manager, change what they can make public or not, and alert their new supervisor in an email. They should be able to add free form text to the email to intro the reason for the email, and add if there any actions or if its just something to be aware of. -- WCAG 2.2 AA guidelines compliant +### User case 3; new job existing login -- Accessibility testing - check in about brand guidelines & accessibility with Alastair +Similar to user 2 they would be able to review on their dashboard what they would like to update and make public. They would update their workplace details and manager contacts on the dashboard. They would then be able to email an invitation to their manager to subscribe to and accept email alerts. -- Info stored on a Supabase DB +## Compliance -- Deployed on Vercel \ No newline at end of file +- [ ] Touch base with Digital Services DPIA will change +- [ ] DPIA completion +- [ ] WCAG 2.2 AA guidelines compliant +- [ ] Accessibility testing - check in about brand guidelines & accessibility with Alastair +- [ ] Info stored on a Supabase DB +- [ ] Deployed on Vercel diff --git a/src/lib/services/database/actions.ts b/src/lib/services/database/actions.ts new file mode 100644 index 0000000..8314c79 --- /dev/null +++ b/src/lib/services/database/actions.ts @@ -0,0 +1,237 @@ +import { supabase } from '$lib/services/supabaseClient'; +import type { + DbResult as Result, + DbResultMany as Results, + Database, + QueryOptions, + FilterOptions +} from './types'; + +type Action = Database['public']['Tables']['actions']['Row']; +type ActionInsert = Database['public']['Tables']['actions']['Insert']; +type ActionUpdate = Database['public']['Tables']['actions']['Update']; + +/** + * Get all actions for a user with optional filtering + */ +export async function getUserActions( + userId: string, + options?: QueryOptions & FilterOptions +): Results { + let query = supabase + .from('actions') + .select('*') + .eq('user_id', userId); + + if (options?.status) { + query = query.eq('status', options.status); + } + + if (options?.isLatest) { + query = query.eq('is_latest', true); + } + + if (options?.orderBy) { + query = query.order(options.orderBy.column, { + ascending: options.orderBy.ascending ?? true + }); + } + + if (options?.limit) { + query = query.limit(options.limit); + } + + if (options?.offset) { + query = query.range(options.offset, options.offset + (options.limit ?? 10) - 1); + } + + const { data, error } = await query; + + if (error) { + return { data: null, error }; + } + + return { data, error: null }; +} + +/** + * Get a single action by ID + */ +export async function getActionById(id: string): Result { + const { data, error } = await supabase + .from('actions') + .select('*') + .eq('id', id) + .single(); + + if (error) { + return { data: null, error }; + } + + return { data, error: null }; +} + +/** + * Get action history for a specific response + */ +export async function getActionHistory( + userId: string, + responseId: string +): Results { + const { data, error } = await supabase + .from('actions') + .select('*') + .eq('user_id', userId) + .eq('response_id', responseId) + .order('version', { ascending: false }); + + if (error) { + return { data: null, error }; + } + + return { data, error: null }; +} + +/** + * Create a new action + */ +export async function createAction( + userId: string, + data: Omit +): Result { + const { data: action, error } = await supabase + .from('actions') + .insert([ + { + ...data, + user_id: userId, + version: 1, + is_latest: true, + status: 'active' + } + ]) + .select() + .single(); + + if (error) { + return { data: null, error }; + } + + return { data: action, error: null }; +} + +/** + * Update an existing action + */ +export async function updateAction( + id: string, + data: Omit +): Result { + // First, get the current action to get its version + const { data: currentAction, error: fetchError } = await supabase + .from('actions') + .select('*') + .eq('id', id) + .single(); + + if (fetchError) { + return { data: null, error: fetchError }; + } + + // Update the current action to not be latest + const { error: updateError } = await supabase + .from('actions') + .update({ is_latest: false }) + .eq('id', id); + + if (updateError) { + return { data: null, error: updateError }; + } + + // Create a new version + const { data: newAction, error: insertError } = await supabase + .from('actions') + .insert([ + { + ...currentAction, + ...data, + id: undefined, // Let Supabase generate a new ID + version: currentAction.version + 1, + is_latest: true, + status: currentAction.status // Preserve the status + } + ]) + .select() + .single(); + + if (insertError) { + return { data: null, error: insertError }; + } + + return { data: newAction, error: null }; +} + +/** + * Archive an action + */ +export async function archiveAction(id: string): Result { + // First, get the current action + const { data: currentAction, error: fetchError } = await supabase + .from('actions') + .select('*') + .eq('id', id) + .single(); + + if (fetchError) { + return { data: null, error: fetchError }; + } + + // Update the current action to not be latest + const { error: updateError } = await supabase + .from('actions') + .update({ is_latest: false }) + .eq('id', id); + + if (updateError) { + return { data: null, error: updateError }; + } + + // Create a new version with archived status + const { data: newAction, error: insertError } = await supabase + .from('actions') + .insert([ + { + ...currentAction, + id: undefined, // Let Supabase generate a new ID + version: currentAction.version + 1, + is_latest: true, + status: 'archived' + } + ]) + .select() + .single(); + + if (insertError) { + return { data: null, error: insertError }; + } + + return { data: newAction, error: null }; +} + +/** + * Get latest actions for a user + */ +export async function getLatestActions(userId: string): Results { + const { data, error } = await supabase + .from('actions') + .select('*') + .eq('user_id', userId) + .eq('is_latest', true) + .order('created_at', { ascending: false }); + + if (error) { + return { data: null, error }; + } + + return { data, error: null }; +} \ No newline at end of file diff --git a/src/lib/services/database/index.ts b/src/lib/services/database/index.ts new file mode 100644 index 0000000..f88a676 --- /dev/null +++ b/src/lib/services/database/index.ts @@ -0,0 +1,6 @@ +export * from "./types"; +export * from "./profiles"; +export * from "./questions"; +export * from "./responses"; +export * from "./actions"; +export * from "./sharing"; \ No newline at end of file diff --git a/src/lib/services/database/profiles.ts b/src/lib/services/database/profiles.ts new file mode 100644 index 0000000..b23e815 --- /dev/null +++ b/src/lib/services/database/profiles.ts @@ -0,0 +1,77 @@ +import { supabase } from '$lib/services/supabaseClient'; +import type { + DbResult as Result, + DbResultMany as Results, + Database +} from './types'; + +type Profile = Database['public']['Tables']['profiles']['Row']; +type ProfileInsert = Database['public']['Tables']['profiles']['Insert']; +type ProfileUpdate = Database['public']['Tables']['profiles']['Update']; + +/** + * Get a user's profile by their user ID + */ +export async function getProfile(userId: string): Result { + const { data, error } = await supabase + .from('profiles') + .select('*') + .eq('user_id', userId) + .single(); + + if (error) { + return { data: null, error }; + } + + return { data, error: null }; +} + +/** + * Create a new profile for a user + */ +export async function createProfile(userId: string, data: ProfileInsert): Result { + const { data: profile, error } = await supabase + .from('profiles') + .insert([{ ...data, user_id: userId }]) + .select() + .single(); + + if (error) { + return { data: null, error }; + } + + return { data: profile, error: null }; +} + +/** + * Update a user's profile + */ +export async function updateProfile(userId: string, data: ProfileUpdate): Result { + const { data: profile, error } = await supabase + .from('profiles') + .update(data) + .eq('user_id', userId) + .select() + .single(); + + if (error) { + return { data: null, error }; + } + + return { data: profile, error: null }; +} + +/** + * Get all profiles (admin function) + */ +export async function getAllProfiles(): Results { + const { data, error } = await supabase + .from('profiles') + .select('*'); + + if (error) { + return { data: null, error }; + } + + return { data, error: null }; +} \ No newline at end of file diff --git a/src/lib/services/database/questions.ts b/src/lib/services/database/questions.ts new file mode 100644 index 0000000..9f5688c --- /dev/null +++ b/src/lib/services/database/questions.ts @@ -0,0 +1,127 @@ +import { supabase } from '$lib/services/supabaseClient'; +import type { + DbResult as Result, + DbResultMany as Results, + Database, + QueryOptions +} from './types'; + +type Question = Database['public']['Tables']['questions']['Row']; +type QuestionInsert = Database['public']['Tables']['questions']['Insert']; +type QuestionUpdate = Database['public']['Tables']['questions']['Update']; + +/** + * Get all questions with optional filtering and pagination + */ +export async function getQuestions(options?: QueryOptions): Results { + let query = supabase + .from('questions') + .select('*'); + + if (options?.orderBy) { + query = query.order(options.orderBy.column, { + ascending: options.orderBy.ascending ?? true + }); + } + + if (options?.limit) { + query = query.limit(options.limit); + } + + if (options?.offset) { + query = query.range(options.offset, options.offset + (options.limit ?? 10) - 1); + } + + const { data, error } = await query; + + if (error) { + return { data: null, error }; + } + + return { data, error: null }; +} + +/** + * Get questions by category + */ +export async function getQuestionsByCategory(category: string): Results { + const { data, error } = await supabase + .from('questions') + .select('*') + .eq('category', category) + .order('order', { ascending: true }); + + if (error) { + return { data: null, error }; + } + + return { data, error: null }; +} + +/** + * Get a single question by ID + */ +export async function getQuestionById(id: string): Result { + const { data, error } = await supabase + .from('questions') + .select('*') + .eq('id', id) + .single(); + + if (error) { + return { data: null, error }; + } + + return { data, error: null }; +} + +/** + * Create a new question + */ +export async function createQuestion(data: QuestionInsert): Result { + const { data: question, error } = await supabase + .from('questions') + .insert([data]) + .select() + .single(); + + if (error) { + return { data: null, error }; + } + + return { data: question, error: null }; +} + +/** + * Update an existing question + */ +export async function updateQuestion(id: string, data: QuestionUpdate): Result { + const { data: question, error } = await supabase + .from('questions') + .update(data) + .eq('id', id) + .select() + .single(); + + if (error) { + return { data: null, error }; + } + + return { data: question, error: null }; +} + +/** + * Delete a question + */ +export async function deleteQuestion(id: string): Result { + const { error } = await supabase + .from('questions') + .delete() + .eq('id', id); + + if (error) { + return { data: null, error }; + } + + return { data: undefined, error: null }; +} \ No newline at end of file diff --git a/src/lib/services/database/responses.ts b/src/lib/services/database/responses.ts new file mode 100644 index 0000000..06a87a3 --- /dev/null +++ b/src/lib/services/database/responses.ts @@ -0,0 +1,218 @@ +import { supabase } from '$lib/services/supabaseClient'; +import type { + DbResult as Result, + DbResultMany as Results, + Database, + QueryOptions, + FilterOptions +} from './types'; + +type Response = Database['public']['Tables']['responses']['Row']; +type ResponseInsert = Database['public']['Tables']['responses']['Insert']; +type ResponseUpdate = Database['public']['Tables']['responses']['Update']; + +/** + * Get all responses for a user with optional filtering + */ +export async function getUserResponses( + userId: string, + options?: QueryOptions & FilterOptions +): Results { + let query = supabase + .from('responses') + .select('*') + .eq('user_id', userId); + + if (options?.visibility) { + query = query.eq('visibility', options.visibility); + } + + if (options?.isLatest) { + query = query.eq('is_latest', true); + } + + if (options?.orderBy) { + query = query.order(options.orderBy.column, { + ascending: options.orderBy.ascending ?? true + }); + } + + if (options?.limit) { + query = query.limit(options.limit); + } + + if (options?.offset) { + query = query.range(options.offset, options.offset + (options.limit ?? 10) - 1); + } + + const { data, error } = await query; + + if (error) { + return { data: null, error }; + } + + return { data, error: null }; +} + +/** + * Get a single response by ID + */ +export async function getResponseById(id: string): Result { + const { data, error } = await supabase + .from('responses') + .select('*') + .eq('id', id) + .single(); + + if (error) { + return { data: null, error }; + } + + return { data, error: null }; +} + +/** + * Get response history for a specific question + */ +export async function getResponseHistory( + userId: string, + questionId: string +): Results { + const { data, error } = await supabase + .from('responses') + .select('*') + .eq('user_id', userId) + .eq('question_id', questionId) + .order('version', { ascending: false }); + + if (error) { + return { data: null, error }; + } + + return { data, error: null }; +} + +/** + * Create a new response + */ +export async function createResponse( + userId: string, + data: Omit +): Result { + // Start a transaction to handle versioning + const { data: response, error } = await supabase + .from('responses') + .insert([ + { + ...data, + user_id: userId, + version: 1, + is_latest: true + } + ]) + .select() + .single(); + + if (error) { + return { data: null, error }; + } + + return { data: response, error: null }; +} + +/** + * Update an existing response + */ +export async function updateResponse( + id: string, + data: Omit +): Result { + // First, get the current response to get its version + const { data: currentResponse, error: fetchError } = await supabase + .from('responses') + .select('*') + .eq('id', id) + .single(); + + if (fetchError) { + return { data: null, error: fetchError }; + } + + // Update the current response to not be latest + const { error: updateError } = await supabase + .from('responses') + .update({ is_latest: false }) + .eq('id', id); + + if (updateError) { + return { data: null, error: updateError }; + } + + // Create a new version + const { data: newResponse, error: insertError } = await supabase + .from('responses') + .insert([ + { + ...currentResponse, + ...data, + id: undefined, // Let Supabase generate a new ID + version: currentResponse.version + 1, + is_latest: true + } + ]) + .select() + .single(); + + if (insertError) { + return { data: null, error: insertError }; + } + + return { data: newResponse, error: null }; +} + +/** + * Skip a question + */ +export async function skipQuestion( + userId: string, + questionId: string +): Result { + const { data, error } = await supabase + .from('responses') + .insert([ + { + user_id: userId, + question_id: questionId, + status: 'skipped', + visibility: 'private', + version: 1, + is_latest: true + } + ]) + .select() + .single(); + + if (error) { + return { data: null, error }; + } + + return { data, error: null }; +} + +/** + * Get latest responses for a user + */ +export async function getLatestResponses(userId: string): Results { + const { data, error } = await supabase + .from('responses') + .select('*') + .eq('user_id', userId) + .eq('is_latest', true) + .order('created_at', { ascending: false }); + + if (error) { + return { data: null, error }; + } + + return { data, error: null }; +} \ No newline at end of file diff --git a/src/lib/services/database/sharing.ts b/src/lib/services/database/sharing.ts new file mode 100644 index 0000000..a05b9aa --- /dev/null +++ b/src/lib/services/database/sharing.ts @@ -0,0 +1,242 @@ +import { supabase } from '$lib/services/supabaseClient'; +import type { + DbResult as Result, + DbResultMany as Results, + Database, + QueryOptions +} from './types'; + +type Share = Database['public']['Tables']['sharing_events']['Row']; +type ShareInsert = Database['public']['Tables']['sharing_events']['Insert']; +type ShareUpdate = Database['public']['Tables']['sharing_events']['Update']; + +/** + * Get all shares for a user (both shared with them and shared by them) + */ +export async function getUserShares( + userId: string, + options?: QueryOptions +): Results { + let query = supabase + .from('shares') + .select('*') + .or(`shared_by.eq.${userId},shared_with.eq.${userId}`); + + if (options?.orderBy) { + query = query.order(options.orderBy.column, { + ascending: options.orderBy.ascending ?? true + }); + } + + if (options?.limit) { + query = query.limit(options.limit); + } + + if (options?.offset) { + query = query.range(options.offset, options.offset + (options.limit ?? 10) - 1); + } + + const { data, error } = await query; + + if (error) { + return { data: null, error }; + } + + return { data, error: null }; +} + +/** + * Get shares where user is the recipient + */ +export async function getSharedWithUser( + userId: string, + options?: QueryOptions +): Results { + let query = supabase + .from('shares') + .select('*') + .eq('shared_with', userId); + + if (options?.orderBy) { + query = query.order(options.orderBy.column, { + ascending: options.orderBy.ascending ?? true + }); + } + + if (options?.limit) { + query = query.limit(options.limit); + } + + if (options?.offset) { + query = query.range(options.offset, options.offset + (options.limit ?? 10) - 1); + } + + const { data, error } = await query; + + if (error) { + return { data: null, error }; + } + + return { data, error: null }; +} + +/** + * Get shares created by a user + */ +export async function getSharedByUser( + userId: string, + options?: QueryOptions +): Results { + let query = supabase + .from('shares') + .select('*') + .eq('shared_by', userId); + + if (options?.orderBy) { + query = query.order(options.orderBy.column, { + ascending: options.orderBy.ascending ?? true + }); + } + + if (options?.limit) { + query = query.limit(options.limit); + } + + if (options?.offset) { + query = query.range(options.offset, options.offset + (options.limit ?? 10) - 1); + } + + const { data, error } = await query; + + if (error) { + return { data: null, error }; + } + + return { data, error: null }; +} + +/** + * Get a single share by ID + */ +export async function getShareById(id: string): Result { + const { data, error } = await supabase + .from('shares') + .select('*') + .eq('id', id) + .single(); + + if (error) { + return { data: null, error }; + } + + return { data, error: null }; +} + +/** + * Create a new share + */ +export async function createShare( + sharedBy: string, + data: Omit +): Result { + const { data: share, error } = await supabase + .from('shares') + .insert([ + { + ...data, + shared_by: sharedBy, + status: 'active', + created_at: new Date().toISOString() + } + ]) + .select() + .single(); + + if (error) { + return { data: null, error }; + } + + return { data: share, error: null }; +} + +/** + * Update an existing share + */ +export async function updateShare( + id: string, + data: Omit +): Result { + const { data: share, error } = await supabase + .from('shares') + .update(data) + .eq('id', id) + .select() + .single(); + + if (error) { + return { data: null, error }; + } + + return { data: share, error: null }; +} + +/** + * Revoke a share + */ +export async function revokeShare(id: string): Result { + const { data: share, error } = await supabase + .from('shares') + .update({ status: 'revoked' }) + .eq('id', id) + .select() + .single(); + + if (error) { + return { data: null, error }; + } + + return { data: share, error: null }; +} + +/** + * Check if a user has access to a shared item + */ +export async function checkShareAccess( + userId: string, + shareId: string +): Promise { + const { data, error } = await supabase + .from('shares') + .select('*') + .eq('id', shareId) + .eq('shared_with', userId) + .eq('status', 'active') + .single(); + + if (error || !data) { + return false; + } + + return true; +} + +/** + * Get all active shares for a specific item + */ +export async function getItemShares( + itemType: 'response' | 'action', + itemId: string +): Results { + const { data, error } = await supabase + .from('shares') + .select('*') + .eq('item_type', itemType) + .eq('item_id', itemId) + .eq('status', 'active'); + + if (error) { + return { data: null, error }; + } + + return { data, error: null }; +} \ No newline at end of file diff --git a/src/lib/services/database/types.ts b/src/lib/services/database/types.ts new file mode 100644 index 0000000..a485abb --- /dev/null +++ b/src/lib/services/database/types.ts @@ -0,0 +1,43 @@ +// Re-export commonly used types +export type { + Database, + Tables, + TablesInsert, + TablesUpdate +} from '$lib/types/supabase'; + +// Common response types +export type DbResult = Promise<{ + data: T | null; + error: Error | null; +}>; + +export type DbResultMany = Promise<{ + data: T[] | null; + error: Error | null; +}>; + +// Common error types +export class DatabaseError extends Error { + constructor(message: string, public code?: string) { + super(message); + this.name = 'DatabaseError'; + } +} + +// Common query options +export interface QueryOptions { + limit?: number; + offset?: number; + orderBy?: { + column: string; + ascending?: boolean; + }; +} + +// Common filter options +export interface FilterOptions { + status?: string; + visibility?: 'public' | 'private'; + isLatest?: boolean; +} \ No newline at end of file diff --git a/database.types.ts b/src/lib/types/supabase.ts similarity index 97% rename from database.types.ts rename to src/lib/types/supabase.ts index 605eea4..d924dc5 100644 --- a/database.types.ts +++ b/src/lib/types/supabase.ts @@ -41,6 +41,7 @@ export type Database = { id: string is_latest: boolean | null response_id: string | null + status: string type: string updated_at: string | null user_id: string | null @@ -52,10 +53,11 @@ export type Database = { id?: string is_latest?: boolean | null response_id?: string | null + status?: string type: string updated_at?: string | null user_id?: string | null - version: number + version?: number } Update: { created_at?: string | null @@ -63,6 +65,7 @@ export type Database = { id?: string is_latest?: boolean | null response_id?: string | null + status?: string type?: string updated_at?: string | null user_id?: string | null @@ -89,7 +92,7 @@ export type Database = { line_manager_name: string | null line_manager_user_id: string | null name: string | null - pronouns: string | null + pronouns: string[] | null updated_at: string | null user_id: string | null } @@ -103,7 +106,7 @@ export type Database = { line_manager_name?: string | null line_manager_user_id?: string | null name?: string | null - pronouns?: string | null + pronouns?: string[] | null updated_at?: string | null user_id?: string | null } @@ -117,7 +120,7 @@ export type Database = { line_manager_name?: string | null line_manager_user_id?: string | null name?: string | null - pronouns?: string | null + pronouns?: string[] | null updated_at?: string | null user_id?: string | null } @@ -155,7 +158,7 @@ export type Database = { updated_at: string | null user_id: string | null version: number - visibility: string | null + visibility: string } Insert: { created_at?: string | null @@ -166,8 +169,8 @@ export type Database = { status?: string | null updated_at?: string | null user_id?: string | null - version: number - visibility?: string | null + version?: number + visibility?: string } Update: { created_at?: string | null @@ -179,7 +182,7 @@ export type Database = { updated_at?: string | null user_id?: string | null version?: number - visibility?: string | null + visibility?: string } Relationships: [ { diff --git a/supabase/migrations/20250528170555_schema_tweaks.sql b/supabase/migrations/20250528170555_schema_tweaks.sql new file mode 100644 index 0000000..01db6ec --- /dev/null +++ b/supabase/migrations/20250528170555_schema_tweaks.sql @@ -0,0 +1,68 @@ +-- 1. Auth: No Changes + +-- 2. Profiles: Pronoun Arrays + -- Pronouns + -- Change pronouns to text array with exactly 3 elements + -- This means we can assign pronouns intelligently + -- ["he", "him", "his"] + -- ["they", "them", "theirs"] + alter table profiles + alter column pronouns type text[] using array[pronouns, '', ''], + add constraint pronouns_length_check check (array_length(pronouns, 1) = 3); + +-- 3. Questions: Unique Order Numbers + -- Order + -- Make order column unique + alter table questions + add constraint questions_order_unique unique ("order"); + +-- 4. Responses: Default Privacy & Version Sequences + -- Visibility + -- Make visibility not null with default 'private' + alter table responses + alter column visibility set not null, + alter column visibility set default 'private'; + + -- Version + -- Create a sequence that increments by 1 when edited + create sequence response_version_seq; + + -- Modify the version column to use the sequence + alter table responses + alter column version set default nextval('response_version_seq'); + + -- Set the current version values to start from the sequence + update responses + set version = nextval('response_version_seq') + where version is not null; + + -- Add check constraint for version based on status + alter table responses + add constraint version_status_check check ( + (status = 'skipped' and version is null) or + (status = 'skipped' and version is not null) or + (status = 'answered' and version is not null) + ); + +-- 5. Actions: Status (Active/Archived) & Version Sequences + -- Status + -- Add status column with active/archived options + alter table actions + add column status text not null default 'active' check (status in ('active', 'archived')); + + -- Version + -- Create a sequence that increments by 1 when edited + create sequence action_version_seq; + + -- Modify the version column to use the sequence + alter table actions + alter column version set default nextval('action_version_seq'); + + -- Set the current version values to start from the sequence + update actions + set version = nextval('action_version_seq') + where version is not null; + +-- 6. Sharing Events: No Changes +-- 7. Sharing Event Responses: No Changes +-- 8. Sharing Event Actions: No Changes \ No newline at end of file diff --git a/supabase/test_data_seed.sql b/supabase/test_data_seed.sql index e95104d..5b8b200 100644 --- a/supabase/test_data_seed.sql +++ b/supabase/test_data_seed.sql @@ -92,11 +92,11 @@ INSERT INTO auth.users ( -- Insert fake profiles INSERT INTO profiles (id, user_id, name, pronouns, job_title, employer_name, line_manager_name, line_manager_email) VALUES - ('550e8400-e29b-41d4-a716-446655440001'::uuid, '550e8400-e29b-41d4-a716-446655440001'::uuid, 'Alex Thompson', 'they/them', 'Software Developer', 'TechCorp Ltd', 'Sarah Wilson', 'sarah.wilson@techcorp.com'), - ('550e8400-e29b-41d4-a716-446655440002'::uuid, '550e8400-e29b-41d4-a716-446655440002'::uuid, 'Jordan Martinez', 'she/her', 'UX Designer', 'Creative Agency', 'Mike Johnson', 'mike.johnson@creative.com'), - ('550e8400-e29b-41d4-a716-446655440003'::uuid, '550e8400-e29b-41d4-a716-446655440003'::uuid, 'Sam Chen', 'he/him', 'Data Analyst', 'DataFlow Inc', 'Lisa Brown', 'lisa.brown@dataflow.com'), - ('550e8400-e29b-41d4-a716-446655440004'::uuid, '550e8400-e29b-41d4-a716-446655440004'::uuid, 'Priya Patel', 'she/her', 'Product Manager', 'InnovateCorp', 'David Kim', 'david.kim@innovatecorp.com'), - ('550e8400-e29b-41d4-a716-446655440005'::uuid, '550e8400-e29b-41d4-a716-446655440005'::uuid, 'Taylor Adams', 'he/him', 'Marketing Specialist', 'BrandWorks', 'Emma Rodriguez', 'emma.rodriguez@brandworks.com'); + ('550e8400-e29b-41d4-a716-446655440001'::uuid, '550e8400-e29b-41d4-a716-446655440001'::uuid, 'Alex Thompson', ARRAY['they', 'them', 'theirs'], 'Software Developer', 'TechCorp Ltd', 'Sarah Wilson', 'sarah.wilson@techcorp.com'), + ('550e8400-e29b-41d4-a716-446655440002'::uuid, '550e8400-e29b-41d4-a716-446655440002'::uuid, 'Jordan Martinez', ARRAY['she', 'her', 'hers'], 'UX Designer', 'Creative Agency', 'Mike Johnson', 'mike.johnson@creative.com'), + ('550e8400-e29b-41d4-a716-446655440003'::uuid, '550e8400-e29b-41d4-a716-446655440003'::uuid, 'Sam Chen', ARRAY['he', 'him', 'his'], 'Data Analyst', 'DataFlow Inc', 'Lisa Brown', 'lisa.brown@dataflow.com'), + ('550e8400-e29b-41d4-a716-446655440004'::uuid, '550e8400-e29b-41d4-a716-446655440004'::uuid, 'Priya Patel', ARRAY['she', 'her', 'hers'], 'Product Manager', 'InnovateCorp', 'David Kim', 'david.kim@innovatecorp.com'), + ('550e8400-e29b-41d4-a716-446655440005'::uuid, '550e8400-e29b-41d4-a716-446655440005'::uuid, 'Taylor Adams', ARRAY['he', 'him', 'his'], 'Marketing Specialist', 'BrandWorks', 'Emma Rodriguez', 'emma.rodriguez@brandworks.com'); -- Insert comprehensive fake responses using actual question IDs from the questions table @@ -239,7 +239,7 @@ SELECT '660e8400-e29b-41d4-a716-446655440023'::uuid, '550e8400-e29b-41d4-a716-446655440002'::uuid, q.id, - 'skipped', + NULL, 'skipped', 'public', 1, @@ -305,7 +305,7 @@ SELECT '660e8400-e29b-41d4-a716-446655440032'::uuid, '550e8400-e29b-41d4-a716-446655440003'::uuid, q.id, - 'skipped', + NULL, 'skipped', 'public', 1, @@ -451,7 +451,7 @@ SELECT '660e8400-e29b-41d4-a716-446655440052'::uuid, '550e8400-e29b-41d4-a716-446655440005'::uuid, q.id, - 'skipped', + NULL, 'skipped', 'public', 1, @@ -463,7 +463,7 @@ SELECT '660e8400-e29b-41d4-a716-446655440053'::uuid, '550e8400-e29b-41d4-a716-446655440005'::uuid, q.id, - 'skipped', + NULL, 'skipped', 'public', 1, diff --git a/utils/emailBuilder.ts b/utils/emailBuilder.ts new file mode 100644 index 0000000..64fee8a --- /dev/null +++ b/utils/emailBuilder.ts @@ -0,0 +1,42 @@ +const jason = makeGrammar('Jason', ['he', 'him', 'his']); +craftEmail(jason); + +const jaz = makeGrammar('Jaz', ['they', 'them', 'theirs']); +craftEmail(jaz); + +function makeGrammar(name: string, pronouns: string[]): User { + return { + name: name, + pro: { + sub: pronouns[0], + obj: pronouns[1], + pos: pronouns[2] + }, + responses: [ /* from the DB */ ], + manager: [ /* from the DB */ ] + }; +} + +function craftEmail(user:User) { + return `Dear ${user.manager}, + + ${user.name} has chosen to share ${user.pro.pos} latest responses with you. + + Have a look at them before your next review with ${user.pro.obj}. + + ${user.responses /* loop through */} + `; +} + +interface User { + name: string; + pro: Pronouns; + responses: [ /* from the DB */ ], + manager: [ /* from the DB */ ] +} + +interface Pronouns { + sub: string; + obj: string; + pos: string; +} \ No newline at end of file