diff --git a/.gitignore b/.gitignore index c553c1d..71d3191 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ # secrets .env /airtable/project-creation-debug-2025-12-20T22-24-34-338Z.json +/docs/architecture/ADR-006-duplicate-management-DRAFT_files/ +/docs/architecture/ADR-006-duplicate-management-DRAFT.html diff --git a/docs/architecture/ADR-006-duplicate-management-DRAFT.md b/docs/architecture/ADR-006-duplicate-management-DRAFT.md new file mode 100644 index 0000000..d937c1a --- /dev/null +++ b/docs/architecture/ADR-006-duplicate-management-DRAFT.md @@ -0,0 +1,458 @@ +# ADR-006: Duplicate Management Strategy + +**Status:** Draft (for discussion) + +**Date:** 2025-12-21 + +**Deciders:** [To be determined] + +**Technical Story:** When creating projects, detect existing duplicates across Airtable, Asana, and Google Drive, and provide users with options to update existing resources or create new ones. + +--- + +## Context and Problem Statement + +The project creation form currently creates new resources in all three platforms (Airtable, Asana, Google Drive) without checking for existing projects with the same name. This creates several problems: + +1. **Accidental duplicates**: Users may re-submit a form, creating duplicate records +2. **Project re-scoping**: Legitimate updates to existing projects require manual cleanup +3. **Resource waste**: Orphaned Airtable records, Asana projects, and Google folders accumulate +4. **Data inconsistency**: Multiple versions of the same project exist across systems + +### Current State + +Duplicate detection functions **already exist** but are not integrated: +- `airtable.ts:checkProjectExists()` - Checks for exact name match in Projects table +- `asana.ts:searchProjectByName()` - Uses Asana typeahead API to find matching projects +- `google.ts:searchDriveFolder()` - Searches for folders by name + +What's missing is: +1. Pre-creation duplicate checks during form submission +2. UI to present duplicates and gather user decisions +3. Logic to update existing resources vs. create new ones +4. Per-platform handling strategies + +--- + +## Decision Drivers + +- **User experience**: Make duplicate handling intuitive, not overwhelming +- **Safety**: Prevent accidental data loss; prefer updates over deletions +- **Consistency**: Apply sensible defaults while allowing overrides +- **Existing patterns**: Follow established modal/dialog patterns (see ShareDraftModal) +- **Configuration**: Allow per-platform defaults to be adjusted in TOML config + +--- + +## Considered Options + +### Option 1: Pre-flight Check with Modal Resolution + +Check all three platforms before any creation, present a single modal showing all duplicates with per-platform choices. + +**Pros:** +- User sees complete picture upfront +- Single decision point +- Can abort entirely if wrong project + +**Cons:** +- Modal could be complex with three platforms +- All-or-nothing: can't easily skip one platform + +### Option 2: Progressive Disclosure (Per-Platform) + +Check each platform as its action is triggered, show inline warnings/options. + +**Pros:** +- Simpler per-step UI +- Users only see what they're about to affect + +**Cons:** +- Fragmented decision-making +- User may not realize duplicates exist until mid-way + +### Option 3: Hybrid - Pre-flight with Inline Details + +Pre-flight check before submission shows summary; detailed per-platform options inline in the form or a structured modal. + +**Pros:** +- Early warning of duplicates +- Detailed control when needed +- Can proceed platform-by-platform + +**Cons:** +- More UI complexity +- Two stages of duplicate handling + +--- + +## Decision Outcome + +**Recommended option: Option 3 - Hybrid Pre-flight with Structured Modal** + +When the user initiates project creation (via "Create Project" button): + +1. **Pre-flight check**: Query all three platforms for duplicates +2. **If no duplicates**: Proceed directly to creation +3. **If duplicates found**: Show **DuplicateResolutionModal** with: + - Summary of what was found (per platform) + - Per-platform options based on default behaviors + - "Create Anyway" vs "Update Existing" choices +4. **Execute**: Proceed with user's selected strategy per platform + +--- + +## Architecture + +### Detection Phase + +``` +User clicks "Create Project" + │ + ▼ +┌─────────────────────────────────┐ +│ Parallel duplicate checks: │ +│ • Airtable: checkProjectExists │ +│ • Asana: searchProjectByName │ +│ • Google: searchDriveFolder │ +└─────────────────────────────────┘ + │ + ▼ + Duplicates found? + │ + No ──┴── Yes + │ │ + ▼ ▼ +Create Show DuplicateResolutionModal +All New with per-platform options +``` + +### Resolution Modal Design + +``` +┌────────────────────────────────────────────────────────────┐ +│ ⚠️ Existing Project Found │ +├────────────────────────────────────────────────────────────┤ +│ │ +│ A project named "Q1 Impact Report" may already exist │ +│ in one or more systems: │ +│ │ +│ ┌─ Airtable ─────────────────────────────────────────┐ │ +│ │ ✓ Match found: "Q1 Impact Report" │ │ +│ │ Created: 2025-01-15 | Lead: Jane Smith │ │ +│ │ [View in Airtable ↗] │ │ +│ │ │ │ +│ │ ○ Update existing record (recommended) │ │ +│ │ ○ Create new record anyway │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ Asana ────────────────────────────────────────────┐ │ +│ │ ✓ Match found: "Q1 Impact Report" │ │ +│ │ [View in Asana ↗] │ │ +│ │ │ │ +│ │ ○ Use existing project & update milestones │ │ +│ │ ○ Create new Asana project │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ Google Drive ─────────────────────────────────────┐ │ +│ │ ✓ Folder found: "Q1 Impact Report" │ │ +│ │ [View folder ↗] │ │ +│ │ │ │ +│ │ ○ Keep existing documents (recommended) │ │ +│ │ ○ Skip Google Drive creation │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ [Cancel] [Continue →] │ +└────────────────────────────────────────────────────────────┘ +``` + +### Per-Platform Strategies + +#### Airtable + +| Option | Behavior | +|--------|----------| +| **Update existing** (default) | Update fields on existing record; merge milestones by name; merge assignments | +| Create new | Create new Project record (ignores existing) | + +**Update logic:** +- Find existing record by name +- Update all scalar fields (dates, description, etc.) +- For milestones: match by name, update due dates; create new if no match +- For assignments: match by role, update member; create new if no match + +#### Asana + +| Option | Behavior | +|--------|----------| +| **Use existing** (default) | Add/update milestones as tasks in existing project | +| Create new | Create new Asana project from template | + +**Update logic:** +- Use existing project GID +- For milestones: search tasks by name, update if found, create if not +- Optionally update project members + +#### Google Drive + +| Option | Behavior | +|--------|----------| +| **Keep existing** (default) | Do not create new folder or documents | +| Skip creation | Same as above (explicit skip) | +| Delete and recreate | (HIDDEN - future option) Delete folder contents, recreate from templates | + +**Rationale:** Google Docs are often manually edited after creation. Overwriting would lose user work. + +### State Management + +```typescript +interface DuplicateCheckResult { + airtable: { + found: boolean; + record?: AirtableRecord; + url?: string; + }; + asana: { + found: boolean; + project?: AsanaProject; + url?: string; + searchResults?: Array<{ name: string; gid: string }>; + }; + google: { + found: boolean; + folderId?: string; + url?: string; + documents?: Array<{ name: string; id: string }>; + }; +} + +interface DuplicateResolution { + airtable: 'update' | 'create_new'; + asana: 'use_existing' | 'create_new'; + google: 'keep' | 'skip' | 'recreate'; +} +``` + +### Configuration + +Add to `src/config/integrations.toml`: + +```toml +[duplicates] +# Check for duplicates before creation +enabled = true + +[duplicates.defaults] +# Default resolution strategies +airtable = "update" # "update" | "create_new" +asana = "use_existing" # "use_existing" | "create_new" +google = "keep" # "keep" | "skip" | "recreate" + +[duplicates.airtable] +# Fields to update when using "update" strategy +update_fields = ["description", "objectives", "start_date", "end_date", "funder", "parent_initiative", "project_type"] +# Whether to merge milestones (match by name) +merge_milestones = true +# Whether to merge assignments (match by role) +merge_assignments = true + +[duplicates.asana] +# Whether to update existing milestones or only add new ones +update_milestones = true +# Whether to sync project members on update +sync_members = true + +[duplicates.google] +# Allow destructive recreate option (requires explicit user confirmation) +allow_recreate = false +``` + +--- + +## Implementation Components + +### New Files + +| File | Purpose | +|------|---------| +| `src/components/ui/DuplicateResolutionModal.tsx` | Modal for duplicate resolution choices | +| `src/services/duplicates.ts` | Orchestrates duplicate checks across platforms | +| `src/hooks/useDuplicateCheck.ts` | React Query hook for duplicate detection | + +### Modified Files + +| File | Changes | +|------|---------| +| `src/pages/ProjectForm.tsx` | Integrate duplicate check into submission flow | +| `src/services/airtable.ts` | Add `updateProject()`, `updateMilestones()` functions | +| `src/services/asana.ts` | Add `updateProjectMilestones()`, `findTaskByName()` functions | +| `src/config/integrations.toml` | Add `[duplicates]` configuration section | +| `src/types/index.ts` | Add duplicate-related type definitions | + +### New Service API + +```typescript +// src/services/duplicates.ts + +// Check all platforms for duplicates in parallel +export async function checkAllDuplicates( + projectName: string, + workspaceGid: string, + sharedDriveId: string +): Promise; + +// Execute creation with resolution strategy +export async function createWithResolution( + formData: FormData, + resolution: DuplicateResolution, + existingResources: DuplicateCheckResult +): Promise; +``` + +```typescript +// additions to src/services/airtable.ts + +// Update existing project record +export async function updateProject( + recordId: string, + projectData: Partial +): Promise; + +// Merge milestones (update existing, create new) +export async function mergeMilestones( + projectId: string, + milestones: Milestone[] +): Promise<{ updated: number; created: number }>; +``` + +```typescript +// additions to src/services/asana.ts + +// Find task by name in project +export async function findTaskByName( + projectGid: string, + taskName: string +): Promise; + +// Update or create milestones in existing project +export async function syncProjectMilestones( + projectGid: string, + milestones: Milestone[] +): Promise<{ updated: number; created: number }>; +``` + +--- + +## Security Considerations + +1. **Destructive operations**: "Delete and recreate" for Google should require explicit confirmation +2. **Audit trail**: Log all update vs. create decisions for debugging +3. **Race conditions**: Check-then-act pattern; another user could create between check and action + - Mitigation: Handle creation errors gracefully, offer retry or switch to update + +--- + +## Trade-offs and Risks + +### Trade-offs + +| Aspect | Trade-off | +|--------|-----------| +| UX complexity | Modal adds a step, but prevents duplicates | +| Google conservatism | Default "keep" may leave stale docs, but prevents data loss | +| Update granularity | Bulk field updates may overwrite intentional differences | +| Asana matching | Name-based matching may miss renamed projects | + +### Risks and Mitigations + +| Risk | Mitigation | +|------|------------| +| False positive matches | Show enough context (dates, owners) for user to decide | +| Update overwrites wanted data | Show diff preview (future enhancement) | +| Partial creation failure | Track what was created; allow retry of failed steps | +| Performance (3 API calls) | Parallel execution; show loading state | + +--- + +## Implementation Phases + +### Phase 1: Foundation +- [ ] Create `DuplicateCheckResult` and `DuplicateResolution` types +- [ ] Create `src/services/duplicates.ts` with `checkAllDuplicates()` +- [ ] Add `[duplicates]` section to `integrations.toml` +- [ ] Create `useDuplicateCheck` hook + +**Checkpoint: Core detection working, can log results** + +### Phase 2: Resolution Modal UI +- [ ] Create `DuplicateResolutionModal.tsx` component +- [ ] Implement platform-specific option UI +- [ ] Add modal trigger to ProjectForm submission flow +- [ ] Handle "no duplicates" case (skip modal) + +**🔲 USER CHECKPOINT: Review modal design and UX flow** + +### Phase 3: Airtable Update Strategy +- [ ] Implement `updateProject()` function +- [ ] Implement `mergeMilestones()` function +- [ ] Implement `mergeAssignments()` function +- [ ] Integrate with resolution handler + +**Checkpoint: Can update existing Airtable projects** + +### Phase 4: Asana Update Strategy +- [ ] Implement `findTaskByName()` function +- [ ] Implement `syncProjectMilestones()` function +- [ ] Handle "use existing project" flow +- [ ] Integrate with resolution handler + +**Checkpoint: Can add milestones to existing Asana projects** + +### Phase 5: Google Drive Strategy +- [ ] Implement "keep existing" (skip creation) logic +- [ ] Document "recreate" option for future implementation +- [ ] Integrate with resolution handler + +**🔲 USER CHECKPOINT: Test full workflow end-to-end** + +### Phase 6: Polish & Edge Cases +- [ ] Error handling for partial failures +- [ ] Loading states during duplicate check +- [ ] Debug logging for duplicate resolution +- [ ] Update help documentation + +--- + +## Open Questions (for Discussion) + +1. **Fuzzy matching**: Should we detect near-matches (e.g., "Q1 Report" vs "Q1 Report 2025")? + - Pro: Catches more potential duplicates + - Con: More false positives, complex UI + +2. **Milestone matching strategy**: By name only, or also consider due dates? + - By name: Simple, may update wrong milestone if renamed + - By name + date range: More accurate, more complex + +3. **Assignment conflicts**: What if existing assignment has different person for same role? + - Overwrite: Uses form data (current approach) + - Keep existing: Preserves manual changes + - Prompt: Ask user (more complexity) + +4. **Google recreate timing**: When should we enable the "delete and recreate" option? + - After testing on staging? + - Behind a feature flag? + - Admin-only setting? + +5. **Batch operations**: Should this integrate with draft approval workflow? + - Approved drafts could auto-check duplicates before creation + +--- + +## References + +- [ADR-004: Draft Approval Workflow](./ADR-004-draft-approval-workflow.md) +- [ROADMAP: Manage Duplicates](../../../project-creation-app/ROADMAP.md#manage-duplicates) +- Existing duplicate check functions: + - `src/services/airtable.ts:checkProjectExists()` + - `src/services/asana.ts:searchProjectByName()` + - `src/services/google.ts:searchDriveFolder()` diff --git a/project-creation-app/ROADMAP.md b/project-creation-app/ROADMAP.md index d76dfd0..1ffe4a0 100644 --- a/project-creation-app/ROADMAP.md +++ b/project-creation-app/ROADMAP.md @@ -44,7 +44,7 @@ We need those fields in order to implement the next step, below. ## Use different asana templates for distinct project types -**STATUS: Complete** +**STATUS: Implemented in Code in PR #6, but project-specific templates have not been specified in `config.toml`** Several project types have well-developed Asana templates with additional project roles. Implementation: @@ -61,9 +61,47 @@ Deploy *only* the project creation helper app, *not* the oauth relay, as the pla **How it works:** Netlify's native GitHub integration automatically creates deploy previews for PRs. The root `netlify.toml` configures `base = "project-creation-app"` for monorepo support. Environment variables are configured in Netlify site settings (sync with `npm run env:sync:execute`). +## Manage Duplicates + +This project needs an explicit strategy for managing duplicates. It is always possible, when interacting with any of the three services, that some version of the project already exists in that form: +- in Airtable, if a a Project with the same `Name` exists, it may be a duplicate or the name may already be "taken". +- in Asana, the same is true: if an Asana Project with the same name exists, it may be an existing board for this project +- in Google docs, if the Project Folder exists and there is a document or slide deck with the expected name, that document is likely to be a duplicate of this one. + +What's required here is: + +1. a way to check for existing duplicate projects in the three platforms before pushing +2. a UI for presenting possible duplicates +3. default and non-default choices for each platform, eg: + a. Airtable: Default behaviour may be updating the existing record & reusing the existing staff Assignments and Milestones, with an option to create new records for all of the above + b. Asana: Default behaviour is probably using the existing Asana Project and updating existing milestones that match certain criteria, or creating new ones if they don't; creating a new project should be the non-default option + c. Google Docs: Default behaviour is likely leaving the docs as is; a non-default option is to delete the existing docs and create new ones, but this should not be exposed to users until we are sure it works. + +In all the above cases, there are difficult UI questions and likely tradeoffs. + +If this is implemented, it will definitely require an ADR, and before beginning work, a draft ADR should be checked in as a vehicle for discussion. + +## Move all resource creation behind the modal, and add Scoping Doc and Asana Board fields to form + +It is still too easy to create duplicates with this form. Proposal: +- near the top of the form, offer to populate from an existing Airtable record. User can enter a URL or search for a partial match. If a match is found, user is offered the chance to populate from that match. Show a preview in a new modal. + +If populating from an existing record, remember that fact & during submission, default to updating that record. But also: Move the resource creation buttons into the modal. That is: + +- Rename "Check For Duplicates" to "Submit and Check". +- Within the modal, perform checks as before. If links to Scoping Doc or Asana Board have already been provided, check to see that they refer to real docs/boards. +- For each resource, offer a set of radio button options as before. Ensure each of them includes a "do nothing" (or similar) option. +- This UI should replace the existing resource creation buttons, which should no longer be accessible directly from the form until after submission. At that point, they should appear at the bottom of the page, and should include the "link" and "recreate" buttons as they do right now. + +Deploy *only* the project creation helper app, *not* the oauth relay, as the platform oauth endpoints need a stable redirect url. + +**How it works:** Netlify's native GitHub integration automatically creates deploy previews for PRs. The root `netlify.toml` configures `base = "project-creation-app"` for monorepo support. Environment variables are configured in Netlify site settings (sync with `npm run env:sync:execute`). + ## Add Auth0 Authentication -The app should use proper authendication based on the GTDVC authentication manager (Auth0) and the self-signup capacity we've recently released. +**Status: TODO** + +The app should use proper authentication based on the GTDVC authentication manager (Auth0) and the self-signup capacity we've recently released. This gives room for additional features: @@ -75,8 +113,12 @@ Such features will require more careful reworkign of some existing features. ## Evaluate whether this can be combined with our project-tracker-app +**Status: Blocked** + The [Project Tracker App](https://github.com/Giving-Tuesday/project-tracker-app) uses the same Airtable data to produce a set of visualizations. It is itself under active development, but when it stabilizes as an MVP we should explore whether it can be published in a single interface with the creation helper. This could be part of a broader expansion of this app into a work tracker that ads analytics to Asana. ## Use GivingTuesday component libraries +**Status: Blocked** + GivingTuesday is in the process of creating a set of standard React components for use across products. As that develops, we should integrate those components as much as possible. diff --git a/project-creation-app/docs/architecture/ADR-006-duplicate-management.md b/project-creation-app/docs/architecture/ADR-006-duplicate-management.md new file mode 100644 index 0000000..2b76949 --- /dev/null +++ b/project-creation-app/docs/architecture/ADR-006-duplicate-management.md @@ -0,0 +1,369 @@ +# ADR-006: Duplicate Management and Resource Creation Flow + +## Status +**Complete** - All phases implemented + +## Context + +The Project Creation Helper creates resources across three platforms: Airtable, Asana, and Google Drive. Without proper duplicate detection, users can accidentally create duplicate projects, leading to: +- Confusion about which project record is authoritative +- Orphaned resources across platforms +- Manual cleanup work + +Additionally, the current form flow makes it too easy to create new resources when the user may want to work with existing ones. + +## Decision + +Implement a multi-phase duplicate management system: + +### Phase 1: Add Existing Resource URL Fields (Current) +Add optional URL input fields to the form allowing users to link existing resources: +- **Scoping Doc URL** - Link to existing Google Doc +- **Asana Board URL** - Link to existing Asana project + +These fields allow users to indicate that resources already exist, preventing unnecessary recreation. + +### Phase 2: Populate from Existing Record +Add ability to populate the form from an existing Airtable project record: +- Search box at top of form: "Start from existing project?" +- User can enter Airtable URL or search by name (substring match) +- Preview modal shows project details before populating +- Track editing vs creating new + +### Phase 3: Restructure Submission Flow +Move all resource creation behind a confirmation modal: +- Rename "Check For Duplicates" → "Submit and Check" +- Remove individual "Create" buttons from main form +- Modal shows each resource with options: Create / Update / Link Existing / Skip +- Validate any provided URLs point to real resources + +### Phase 4: Post-Submission Resource Management +After submission, provide resource management at bottom of page: +- Link existing resources +- Recreate resources if needed + +## Duplicate Detection Algorithm + +### Matching Strategy: Substring Match (Case-Insensitive) +We use substring matching rather than exact matching because: +- Project names often have slight variations (trailing spaces, prefixes) +- Users may search with partial names like "FEP" or "Day Of" +- Better to show potential matches than miss real duplicates + +**Airtable:** +``` +OR( + FIND(LOWER(search), LOWER({Project})) > 0, + FIND(LOWER({Project}), LOWER(search)) > 0 +) +``` + +**Asana:** +- Use typeahead API for initial search +- Filter results: `projectName.includes(searchTerm) || searchTerm.includes(projectName)` + +**Google Drive:** +- Search by exact folder name (Google's API limitation) +- Case-insensitive comparison on results + +### Resolution Options + +| Platform | Options | +|----------|---------| +| Airtable | Update existing / Create new / Skip | +| Asana | Use existing / Create new / Skip | +| Google Drive | Keep existing / Create new / Skip | + +## Implementation Details + +### Phase 1 Implementation (Complete) + +#### New Form Fields + +Added to `fields.toml`: +```toml +[fields.existing_scoping_doc_url] +type = "url" +label = "Existing Scoping Doc URL" +required = false +placeholder = "https://docs.google.com/document/d/..." +section = "basics" +help_text = "If a scoping document already exists, paste its URL here to skip creation" +validation_pattern = "docs\\.google\\.com/document/d/" + +[fields.existing_asana_url] +type = "url" +label = "Existing Asana Board URL" +required = false +placeholder = "https://app.asana.com/..." +section = "basics" +help_text = "If an Asana project already exists, paste its URL here to skip creation" +validation_pattern = "app\\.asana\\.com/" +``` + +#### Type Updates (`types/index.ts`) + +Added fields to `FormData`: +```typescript +existingScopingDocUrl?: string; +existingAsanaUrl?: string; +``` + +Added `userProvided` flag to duplicate result types: +```typescript +interface AsanaDuplicateResult { + // ... existing fields + userProvided?: boolean; // True if user provided URL via form + skipped?: boolean; // True if check was skipped +} + +interface GoogleDuplicateResult { + // ... existing fields + userProvided?: boolean; +} +``` + +#### Duplicate Check Integration (`duplicates.ts`) + +Extended `CheckAllDuplicatesParams` to accept existing URLs: +```typescript +existingUrls?: { + asanaUrl?: string; + scopingDocUrl?: string; +}; +``` + +When existing URLs are provided: +1. Skip the duplicate check for that platform +2. Return `found: true` with `userProvided: true` flag +3. Include the user-provided URL in the result + +#### Resource Creation Flow (`ProjectForm.tsx`) + +Updated `executeCreateAll()` to: +1. Check for existing URLs before creating resources +2. Use existing URLs directly instead of creating new resources +3. Skip Asana creation if `existingAsanaUrl` provided +4. Skip Google Drive folder/doc creation if `existingScopingDocUrl` provided +5. Still include the URLs when creating Airtable record + +#### UI Changes + +Added "Link Existing Resources (Optional)" section in the Basics form section with: +- Scoping Doc URL input with Google Docs pattern validation +- Asana Board URL input with Asana URL pattern validation +- Helper text explaining the skip-creation behavior + +### URL Validation +- Google Docs: Must match `docs\.google\.com/document/d/` +- Asana: Must match `app\.asana\.com/` +- Future: Validate URLs point to accessible resources via API + +### Phase 2 Implementation (Complete) + +#### New Service Functions (`airtable.ts`) + +Added project search and fetch functions: +```typescript +// Search projects by name (substring match) +searchProjects(searchTerm: string, maxResults?: number): Promise + +// Get full project data by ID +getProjectById(projectId: string): Promise + +// Get milestones for a project +getProjectMilestones(projectId: string): Promise + +// Get assignments for a project +getProjectAssignments(projectId: string): Promise + +// Parse Airtable URL to extract record ID +parseAirtableUrl(url: string): { recordId: string | null; baseId: string | null } +``` + +#### New Components + +**ProjectSearch** (`src/components/ui/ProjectSearch.tsx`) +- Collapsible search box shown at top of form +- Supports name search with debouncing (300ms) +- Supports direct Airtable URL paste +- Shows search results with project name, acronym, status, and dates +- Click result to open preview modal + +**ProjectPreviewModal** (`src/components/ui/ProjectPreviewModal.tsx`) +- Fetches full project data including milestones and assignments +- Displays project info, dates, description preview +- Shows warning if project has existing resources (Asana, Scoping Doc) +- Lists team assignments and milestones +- "Load Project Data" button to populate form + +#### Form Population Logic (`ProjectForm.tsx`) + +**State tracking:** +```typescript +editingExistingProject: string | null // Airtable record ID if editing +``` + +**Population handler:** +1. Clears current form and created resources +2. Populates all basic fields (name, acronym, dates, description, etc.) +3. Populates linked record fields (funder, parent initiative) +4. Pre-fills existing resource URLs if project has Asana/Scoping Doc +5. Maps Airtable role values back to form role keys +6. Populates team assignments with member IDs and FTE +7. Clears existing outcomes and adds milestones from project +8. Sets `editingExistingProject` state + +**Editing mode indicator:** +- Blue banner shows when editing from existing project +- "Start Fresh" button clears form and exits editing mode + +### Phase 3 Implementation (Complete) + +#### Button Consolidation + +Replaced dual-button approach with single "Submit and Check" button: +- Removed "Create All Resources" and individual platform buttons +- Single button initiates duplicate check then shows modal +- Modal handles all resource creation decisions + +#### Updated Actions Section (`ProjectForm.tsx`) + +**Before:** +- "Check for Duplicates" button +- "Create All Resources" button +- Individual ActionButtons for each platform (Asana, Google Docs, Airtable) + +**After:** +- Single "Submit and Check" button +- Created Resources Status section (shows after creation) +- Cleaner UX with all creation moved to modal + +#### Enhanced DuplicateResolutionModal + +Completely redesigned modal to show all platforms: + +**Platform Status Types:** +- `will_create` (green) - No duplicate found, will create new +- `duplicate_found` (amber) - Existing project found, show options +- `user_provided` (blue) - User linked existing URL, skip creation +- `not_connected` (gray) - Service not connected + +**Visual Summary Bar:** +Shows counts for each status type at a glance + +**Per-Platform Display:** +- Shows appropriate status badge and color +- Duplicate found: Radio options for resolution (including skip) +- Will create: Radio options for Create (recommended) or Skip +- User provided: Shows linked status with URL + +**Skip Options:** +Users can skip resource creation for any platform, even when no duplicate is found: +- Airtable: "Skip Airtable" - Don't create or update any Airtable records +- Asana: "Skip Asana" - Don't create or update any Asana project +- Google Drive: "Skip Google Drive" - Don't create any Google Drive resources + +This allows users to selectively create resources on only the platforms they need. + +**Footer Actions:** +- "Cancel" to close without action +- "Create Resources" to proceed with selections + +#### Skip Resolution Handling (`ProjectForm.tsx`) + +The `executeCreateAll` function checks resolution choices before creating each resource: +```typescript +const skipAsana = resolution?.asana === 'skip'; +const skipGoogle = resolution?.google === 'skip'; +const skipAirtable = resolution?.airtable === 'skip'; + +// Each platform creation is gated by its skip flag +if (connectionStatus.asana && !skipAsana) { /* create Asana */ } +if (connectionStatus.google && !skipGoogle) { /* create Google Drive */ } +if (connectionStatus.airtable && !skipAirtable) { /* create Airtable */ } +``` + +### Phase 4 Implementation (Complete) + +#### ResourceManagement Component + +New component `src/components/ui/ResourceManagement.tsx` provides post-submission resource management: + +**Features:** +- Shows status of all resources (created vs missing) +- "Link URL" button for missing resources - enter existing URL +- "Create Now" button for missing resources - create individually +- Only shows when Airtable record exists but other resources are missing +- Automatically updates Airtable record when linking URLs + +**Resource Row States:** +- **Created**: Shows checkmark with View link +- **Missing (connected)**: Shows Link URL and Create Now buttons +- **Missing (not connected)**: Shows grayed out "Not connected" + +#### Handler Functions (`ProjectForm.tsx`) + +**`handleLinkResource(resourceKey, url)`:** +1. Validates URL format for resource type +2. Updates Airtable record with URL via API +3. Updates local `createdResources` state + +**`handleCreateIndividualResource(resourceType)`:** +1. Calls existing creation handler (e.g., `handleCreateScopingDoc`) +2. Updates Airtable record with new URL +3. Works for: Asana, Scoping Doc, Kickoff Deck, Google Folder + +**Airtable Field Mapping:** +```typescript +const fieldMap = { + asanaUrl: 'Asana Board', + scopingDocUrl: 'Scoping Doc', + kickoffDeckUrl: 'Kickoff Deck', + folderUrl: 'Project Folder', +}; +``` + +## Consequences + +### Positive +- Prevents accidental duplicate creation +- Allows linking to existing resources +- Clearer user flow with explicit confirmation +- Substring matching catches more potential duplicates + +### Negative +- More complex submission flow +- Substring matching may show false positives +- Additional API calls for validation + +### Risks +- Users may find the modal flow cumbersome +- Substring matching could be too aggressive for short project names + +## Files Changed + +### Phase 1 +- `src/config/fields.toml` - Added new URL field configurations +- `src/types/index.ts` - Added `existingScopingDocUrl`, `existingAsanaUrl` to FormData; added `userProvided`/`skipped` flags to duplicate result types +- `src/services/duplicates.ts` - Extended `CheckAllDuplicatesParams` with `existingUrls`; updated `checkAllDuplicates` to skip checks when URLs provided +- `src/components/form/FormComponents.tsx` - Added fields to `DEFAULT_FORM_VALUES` +- `src/pages/ProjectForm.tsx` - Added UI for existing URL inputs; integrated into duplicate check and creation flows + +### Phase 2 +- `src/services/airtable.ts` - Added `searchProjects`, `getProjectById`, `getProjectMilestones`, `getProjectAssignments`, `parseAirtableUrl` functions +- `src/components/ui/ProjectSearch.tsx` - New component for searching existing projects +- `src/components/ui/ProjectPreviewModal.tsx` - New modal for previewing project before loading +- `src/pages/ProjectForm.tsx` - Added search/preview integration, form population handlers, editing mode tracking + +### Phase 3 +- `src/pages/ProjectForm.tsx` - Replaced dual buttons with single "Submit and Check"; removed individual ActionButtons; added Created Resources Status section +- `src/components/ui/DuplicateResolutionModal.tsx` - Complete redesign with platform status types, summary bar, enhanced visual display for all states + +### Phase 4 +- `src/components/ui/ResourceManagement.tsx` - New component for post-submission resource linking and creation +- `src/pages/ProjectForm.tsx` - Added `handleLinkResource` and `handleCreateIndividualResource` handlers; integrated ResourceManagement component + +## References +- ROADMAP.md lines 79-89 +- integrations.toml `[duplicates]` configuration section diff --git a/project-creation-app/src/components/form/FormComponents.tsx b/project-creation-app/src/components/form/FormComponents.tsx index 9b3142b..48eeb4d 100644 --- a/project-creation-app/src/components/form/FormComponents.tsx +++ b/project-creation-app/src/components/form/FormComponents.tsx @@ -186,9 +186,10 @@ interface OutcomeItemProps { onRemove: () => void; canRemove: boolean; disabled?: boolean; + teamMembers?: TeamMember[]; } -export function OutcomeItem({ index, register, onRemove, canRemove, disabled = false }: OutcomeItemProps) { +export function OutcomeItem({ index, register, onRemove, canRemove, disabled = false, teamMembers = [] }: OutcomeItemProps) { return (
@@ -223,12 +224,27 @@ export function OutcomeItem({ index, register, onRemove, canRemove, disabled = f {...register(`outcomes.${index}.description`)} /> - +
+ + + +
); @@ -241,9 +257,10 @@ interface OutcomesSectionProps { append: UseFieldArrayAppend; remove: UseFieldArrayRemove; disabled?: boolean; + teamMembers?: TeamMember[]; } -export function OutcomesSection({ fields, register, append, remove, disabled = false }: OutcomesSectionProps) { +export function OutcomesSection({ fields, register, append, remove, disabled = false, teamMembers = [] }: OutcomesSectionProps) { return (
{fields.map((field, index) => ( @@ -254,6 +271,7 @@ export function OutcomesSection({ fields, register, append, remove, disabled = f onRemove={() => remove(index)} canRemove={fields.length > 1} disabled={disabled} + teamMembers={teamMembers} /> ))} @@ -291,4 +309,6 @@ export const DEFAULT_FORM_VALUES: FormData = { funder: '', parentInitiative: '', projectType: '', + existingScopingDocUrl: '', + existingAsanaUrl: '', }; diff --git a/project-creation-app/src/components/ui/DuplicateResolutionModal.tsx b/project-creation-app/src/components/ui/DuplicateResolutionModal.tsx new file mode 100644 index 0000000..b99a39d --- /dev/null +++ b/project-creation-app/src/components/ui/DuplicateResolutionModal.tsx @@ -0,0 +1,576 @@ +import { useState, useRef, useEffect } from 'react'; +import { + XMarkIcon, + ArrowTopRightOnSquareIcon, + CheckCircleIcon, + PlusCircleIcon, + LinkIcon, + ArrowPathIcon, +} from '@heroicons/react/24/outline'; +import type { + DuplicateCheckResult, + DuplicateResolution, + DuplicateDefaults, + AirtableResolution, + AsanaResolution, + GoogleResolution, +} from '../../types'; + +interface DuplicateResolutionModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: (resolution: DuplicateResolution) => void; + checkResult: DuplicateCheckResult; + defaults: DuplicateDefaults; + isLoading?: boolean; + /** Whether to show the Google "recreate" option (dangerous, hidden by default) */ + allowGoogleRecreate?: boolean; +} + +// Platform status types +type PlatformStatus = 'will_create' | 'duplicate_found' | 'user_provided' | 'not_connected'; + +interface PlatformConfig { + status: PlatformStatus; + url?: string; + details?: string; + userProvided?: boolean; +} + +// Platform-specific icons +function AirtableIcon() { + return ( + + + + ); +} + +function AsanaIcon() { + return ( + + + + + + ); +} + +function GoogleDriveIcon() { + return ( + + + + + ); +} + +interface RadioOptionProps { + name: string; + value: string; + checked: boolean; + onChange: () => void; + label: string; + description: string; + recommended?: boolean; +} + +function RadioOption({ name, value, checked, onChange, label, description, recommended }: RadioOptionProps) { + return ( + + ); +} + +// Simplified platform section that shows status and options +interface PlatformSectionProps { + title: string; + icon: React.ReactNode; + config: PlatformConfig; + children?: React.ReactNode; +} + +function PlatformSection({ title, icon, config, children }: PlatformSectionProps) { + const { status, url, details, userProvided } = config; + + // User provided URL - show link status + if (status === 'user_provided') { + return ( +
+
+
{icon}
+
+
+

{title}

+ + + Linked + +
+

Using existing URL you provided

+
+ {url && ( + + + + )} +
+
+ ); + } + + // Will create new (no duplicate found) - but still show skip option + if (status === 'will_create') { + return ( +
+
+
{icon}
+
+
+

{title}

+ + + Ready + +
+

No existing project found

+ {children &&
{children}
} +
+
+
+ ); + } + + // Duplicate found - show options + if (status === 'duplicate_found') { + return ( +
+
+
{icon}
+
+
+

{title}

+ + Match found + + {url && ( + + View + + )} +
+ {details &&

{details}

} + {children &&
{children}
} +
+
+
+ ); + } + + // Not connected + return ( +
+
+
{icon}
+
+

{title}

+

Not connected

+
+
+
+ ); +} + +export default function DuplicateResolutionModal({ + isOpen, + onClose, + onConfirm, + checkResult, + defaults, + isLoading = false, + allowGoogleRecreate = false, +}: DuplicateResolutionModalProps) { + const modalRef = useRef(null); + + // Resolution state + const [airtableChoice, setAirtableChoice] = useState(defaults.airtable); + const [asanaChoice, setAsanaChoice] = useState(defaults.asana); + const [googleChoice, setGoogleChoice] = useState(defaults.google); + + // Reset choices when modal opens or defaults change + useEffect(() => { + if (isOpen) { + setAirtableChoice(defaults.airtable); + setAsanaChoice(defaults.asana); + setGoogleChoice(defaults.google); + } + }, [isOpen, defaults]); + + // Close on click outside + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (modalRef.current && !modalRef.current.contains(event.target as Node)) { + if (!isLoading) onClose(); + } + } + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + } + }, [isOpen, onClose, isLoading]); + + // Close on escape key + useEffect(() => { + function handleEscape(event: KeyboardEvent) { + if (event.key === 'Escape' && !isLoading) { + onClose(); + } + } + + if (isOpen) { + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); + } + }, [isOpen, onClose, isLoading]); + + const handleConfirm = () => { + onConfirm({ + airtable: airtableChoice, + asana: asanaChoice, + google: googleChoice, + }); + }; + + if (!isOpen) return null; + + const { airtable, asana, google } = checkResult; + + // Determine platform configurations + const airtableConfig: PlatformConfig = { + status: airtable.found ? 'duplicate_found' : 'will_create', + url: airtable.url, + details: airtable.createdTime ? `Created: ${new Date(airtable.createdTime).toLocaleDateString()}` : undefined, + }; + + const asanaConfig: PlatformConfig = { + status: (asana as { userProvided?: boolean }).userProvided + ? 'user_provided' + : asana.found + ? 'duplicate_found' + : 'will_create', + url: asana.url, + details: asana.project?.name ? `Project: ${asana.project.name}` : undefined, + userProvided: (asana as { userProvided?: boolean }).userProvided, + }; + + const googleConfig: PlatformConfig = { + status: (google as { userProvided?: boolean }).userProvided + ? 'user_provided' + : google.found + ? 'duplicate_found' + : 'will_create', + url: google.url, + details: google.folderName ? `Folder: ${google.folderName}` : undefined, + userProvided: (google as { userProvided?: boolean }).userProvided, + }; + + // Count what will happen + const willCreate = [airtableConfig, asanaConfig, googleConfig].filter(c => c.status === 'will_create').length; + const hasMatches = [airtableConfig, asanaConfig, googleConfig].filter(c => c.status === 'duplicate_found').length; + const hasLinked = [airtableConfig, asanaConfig, googleConfig].filter(c => c.status === 'user_provided').length; + + return ( +
+
+ {/* Header */} +
0 ? 'bg-amber-500' : 'bg-blue-600'}`}> + {hasMatches > 0 ? ( + + ) : ( + + )} +

+ {hasMatches > 0 ? 'Review and Create Resources' : 'Create Resources'} +

+ +
+ + {/* Summary bar */} +
+ {willCreate > 0 && ( + + + {willCreate} to create + + )} + {hasMatches > 0 && ( + + + {hasMatches} existing found + + )} + {hasLinked > 0 && ( + + + {hasLinked} linked + + )} +
+ + {/* Content */} +
+

+ {hasMatches > 0 + ? 'Existing resources were found. Choose how to handle each platform:' + : 'Ready to create resources across all platforms:'} +

+ +
+ {/* Airtable Section */} + } + config={airtableConfig} + > + {airtableConfig.status === 'duplicate_found' ? ( + <> + setAirtableChoice('update')} + label="Update existing record" + description="Update the existing project record with new data" + recommended + /> + setAirtableChoice('create_new')} + label="Create new record" + description="Create a new project record alongside the existing one" + /> + setAirtableChoice('skip')} + label="Skip Airtable" + description="Don't create or update any Airtable records" + /> + + ) : airtableConfig.status === 'will_create' ? ( + <> + setAirtableChoice('create_new')} + label="Create new record" + description="Create project, milestones, and assignments in Airtable" + recommended + /> + setAirtableChoice('skip')} + label="Skip Airtable" + description="Don't create any Airtable records" + /> + + ) : null} + + + {/* Asana Section */} + } + config={asanaConfig} + > + {asanaConfig.status === 'duplicate_found' ? ( + <> + setAsanaChoice('use_existing')} + label="Use existing project" + description="Add milestones to the existing Asana project" + recommended + /> + setAsanaChoice('create_new')} + label="Create new project" + description="Create a new Asana project from template" + /> + setAsanaChoice('skip')} + label="Skip Asana" + description="Don't create or update any Asana project" + /> + + ) : asanaConfig.status === 'will_create' ? ( + <> + setAsanaChoice('create_new')} + label="Create new project" + description="Create Asana project from template with milestones" + recommended + /> + setAsanaChoice('skip')} + label="Skip Asana" + description="Don't create any Asana project" + /> + + ) : null} + + + {/* Google Drive Section */} + } + config={googleConfig} + > + {googleConfig.status === 'duplicate_found' ? ( + <> + setGoogleChoice('keep')} + label="Keep existing documents" + description="Skip Google Drive creation, use existing folder" + recommended + /> + setGoogleChoice('skip')} + label="Skip Google Drive" + description="Don't create any Google Drive resources" + /> + {allowGoogleRecreate && ( + setGoogleChoice('recreate')} + label="Delete and recreate" + description="Warning: Deletes existing documents" + /> + )} + + ) : googleConfig.status === 'will_create' ? ( + <> + setGoogleChoice('keep')} + label="Create folder and documents" + description="Create project folder, scoping doc, and kickoff deck" + recommended + /> + setGoogleChoice('skip')} + label="Skip Google Drive" + description="Don't create any Google Drive resources" + /> + + ) : null} + +
+
+ + {/* Footer */} +
+ + +
+
+
+ ); +} diff --git a/project-creation-app/src/components/ui/ProjectPreviewModal.tsx b/project-creation-app/src/components/ui/ProjectPreviewModal.tsx new file mode 100644 index 0000000..a95ad93 --- /dev/null +++ b/project-creation-app/src/components/ui/ProjectPreviewModal.tsx @@ -0,0 +1,314 @@ +// Modal to preview existing project before populating the form +import { useState, useEffect } from 'react'; +import { + XMarkIcon, + ArrowPathIcon, + ArrowTopRightOnSquareIcon, + CheckCircleIcon, + ExclamationTriangleIcon, +} from '@heroicons/react/24/outline'; +import { + getProjectById, + getProjectMilestones, + getProjectAssignments, + type FullProjectData, + type MilestoneRecord, + type AssignmentRecord, +} from '../../services/airtable'; +import type { TeamMember } from '../../types'; + +interface ProjectPreviewModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: (data: PopulateFormData) => void; + projectId: string | null; + teamMembers: TeamMember[]; +} + +export interface PopulateFormData { + project: FullProjectData; + milestones: MilestoneRecord[]; + assignments: AssignmentRecord[]; +} + +export default function ProjectPreviewModal({ + isOpen, + onClose, + onConfirm, + projectId, + teamMembers, +}: ProjectPreviewModalProps) { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [project, setProject] = useState(null); + const [milestones, setMilestones] = useState([]); + const [assignments, setAssignments] = useState([]); + + // Fetch project data when projectId changes + useEffect(() => { + if (!isOpen || !projectId) { + return; + } + + const fetchData = async () => { + setIsLoading(true); + setError(null); + + try { + const [projectData, milestonesData, assignmentsData] = await Promise.all([ + getProjectById(projectId), + getProjectMilestones(projectId), + getProjectAssignments(projectId), + ]); + + if (!projectData) { + setError('Project not found'); + return; + } + + setProject(projectData); + setMilestones(milestonesData); + setAssignments(assignmentsData); + } catch (err) { + setError((err as Error).message || 'Failed to load project'); + } finally { + setIsLoading(false); + } + }; + + fetchData(); + }, [isOpen, projectId]); + + // Reset state when modal closes + useEffect(() => { + if (!isOpen) { + setProject(null); + setMilestones([]); + setAssignments([]); + setError(null); + } + }, [isOpen]); + + const handleConfirm = () => { + if (project) { + onConfirm({ project, milestones, assignments }); + } + }; + + // Get team member name by ID + const getTeamMemberName = (memberId: string): string => { + const member = teamMembers.find(m => m.id === memberId); + return member?.name || 'Unknown'; + }; + + // Check if project has existing resources + const hasExistingResources = project?.asanaUrl || project?.scopingDocUrl || project?.folderUrl; + + if (!isOpen) return null; + + return ( +
+
+ {/* Backdrop */} +
+ + {/* Modal */} +
+ {/* Header */} +
+

+ Load Existing Project +

+ +
+ + {/* Content */} +
+ {isLoading && ( +
+ + Loading project details... +
+ )} + + {error && ( +
+ + {error} +
+ )} + + {project && !isLoading && ( +
+ {/* Project Info */} +
+

{project.name}

+
+ {project.acronym && ( + + {project.acronym} + + )} + {project.status && ( + + {project.status} + + )} + + View in Airtable + + +
+
+ + {/* Dates */} + {(project.startDate || project.endDate) && ( +
+ {project.startDate && ( +
+
Start Date
+
{project.startDate}
+
+ )} + {project.endDate && ( +
+
End Date
+
{project.endDate}
+
+ )} +
+ )} + + {/* Description */} + {project.description && ( +
+
Description
+
+ {project.description} +
+
+ )} + + {/* Existing Resources Warning */} + {hasExistingResources && ( +
+
+ +
+

This project has existing resources

+

+ The existing resource URLs will be pre-filled in the form. +

+
+ {project.asanaUrl && ( +
+ + Asana Board +
+ )} + {project.scopingDocUrl && ( +
+ + Scoping Document +
+ )} + {project.folderUrl && ( +
+ + Google Drive Folder +
+ )} +
+
+
+
+ )} + + {/* Team Assignments */} + {assignments.length > 0 && ( +
+
Team
+
+ {assignments.map((a) => ( +
+ {getTeamMemberName(a.teamMemberId)} + {a.role}{a.fte ? ` (${a.fte}% FTE)` : ''} +
+ ))} +
+
+ )} + + {/* Milestones */} + {milestones.length > 0 && ( +
+
+ Milestones ({milestones.length}) +
+
+ {milestones.slice(0, 5).map((m) => ( +
+ {m.name} + {m.dueDate && ( + {m.dueDate} + )} +
+ ))} + {milestones.length > 5 && ( +

+ +{milestones.length - 5} more milestones +

+ )} +
+
+ )} +
+ )} +
+ + {/* Footer */} +
+

+ This will replace current form data +

+
+ + +
+
+
+
+
+ ); +} diff --git a/project-creation-app/src/components/ui/ProjectSearch.tsx b/project-creation-app/src/components/ui/ProjectSearch.tsx new file mode 100644 index 0000000..ccbcb5d --- /dev/null +++ b/project-creation-app/src/components/ui/ProjectSearch.tsx @@ -0,0 +1,226 @@ +// Project search component for finding existing projects +import { useState, useEffect, useRef, useCallback } from 'react'; +import { + MagnifyingGlassIcon, + XMarkIcon, + ArrowPathIcon, + DocumentDuplicateIcon, +} from '@heroicons/react/24/outline'; +import { searchProjects, parseAirtableUrl, getProjectById, type ProjectSearchResult } from '../../services/airtable'; +import { getConnectionStatus } from '../../services/oauth'; + +interface ProjectSearchProps { + onSelectProject: (projectId: string) => void; + disabled?: boolean; +} + +export default function ProjectSearch({ onSelectProject, disabled = false }: ProjectSearchProps) { + const [isOpen, setIsOpen] = useState(false); + const [searchTerm, setSearchTerm] = useState(''); + const [results, setResults] = useState([]); + const [isSearching, setIsSearching] = useState(false); + const [error, setError] = useState(null); + const searchTimeout = useRef(null); + const containerRef = useRef(null); + + const connectionStatus = getConnectionStatus(); + const isConnected = connectionStatus.airtable; + + // Debounced search + const performSearch = useCallback(async (term: string) => { + if (!term.trim()) { + setResults([]); + return; + } + + // Check if it's an Airtable URL + if (term.includes('airtable.com')) { + const { recordId } = parseAirtableUrl(term); + if (recordId) { + setIsSearching(true); + setError(null); + try { + const project = await getProjectById(recordId); + if (project) { + setResults([{ + id: project.id, + name: project.name, + acronym: project.acronym, + startDate: project.startDate, + endDate: project.endDate, + status: project.status, + url: project.url, + }]); + } else { + setError('Project not found'); + setResults([]); + } + } catch (err) { + setError('Failed to fetch project'); + setResults([]); + } finally { + setIsSearching(false); + } + return; + } + } + + // Regular name search + setIsSearching(true); + setError(null); + try { + const searchResults = await searchProjects(term, 8); + setResults(searchResults); + if (searchResults.length === 0) { + setError('No matching projects found'); + } + } catch (err) { + setError('Search failed'); + setResults([]); + } finally { + setIsSearching(false); + } + }, []); + + // Handle input change with debounce + const handleInputChange = (e: React.ChangeEvent) => { + const value = e.target.value; + setSearchTerm(value); + setError(null); + + if (searchTimeout.current) { + clearTimeout(searchTimeout.current); + } + + if (value.trim()) { + searchTimeout.current = setTimeout(() => { + performSearch(value); + }, 300); + } else { + setResults([]); + } + }; + + // Handle project selection + const handleSelect = (project: ProjectSearchResult) => { + onSelectProject(project.id); + setIsOpen(false); + setSearchTerm(''); + setResults([]); + }; + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + // Clean up timeout on unmount + useEffect(() => { + return () => { + if (searchTimeout.current) { + clearTimeout(searchTimeout.current); + } + }; + }, []); + + if (!isConnected) { + return null; + } + + return ( +
+ {/* Collapsed state - just a button */} + {!isOpen ? ( + + ) : ( + /* Expanded state - search input and results */ +
+
+
+ {isSearching ? ( + + ) : ( + + )} +
+ + +
+ + {/* Results dropdown */} + {(results.length > 0 || error) && ( +
+ {error && ( +
+ {error} +
+ )} + {results.map((project) => ( + + ))} +
+ )} +
+ )} +
+ ); +} diff --git a/project-creation-app/src/components/ui/ResourceManagement.tsx b/project-creation-app/src/components/ui/ResourceManagement.tsx new file mode 100644 index 0000000..f89088a --- /dev/null +++ b/project-creation-app/src/components/ui/ResourceManagement.tsx @@ -0,0 +1,366 @@ +// Post-submission resource management component +// Allows users to link existing resources or create missing ones after initial submission + +import { useState } from 'react'; +import { + CheckCircleIcon, + XCircleIcon, + LinkIcon, + PlusCircleIcon, + ArrowPathIcon, + ArrowTopRightOnSquareIcon, +} from '@heroicons/react/24/outline'; +import type { CreatedResources, ConnectionStatus } from '../../types'; + +interface ResourceConfig { + key: keyof CreatedResources; + urlKey: keyof CreatedResources; + label: string; + placeholder: string; + urlPattern: RegExp; + service: keyof ConnectionStatus; + canCreate: boolean; +} + +const RESOURCE_CONFIGS: ResourceConfig[] = [ + { + key: 'airtableProjectId', + urlKey: 'airtableUrl', + label: 'Airtable Record', + placeholder: 'https://airtable.com/...', + urlPattern: /airtable\.com/, + service: 'airtable', + canCreate: false, // Airtable is always created first + }, + { + key: 'asanaProjectGid', + urlKey: 'asanaUrl', + label: 'Asana Project', + placeholder: 'https://app.asana.com/...', + urlPattern: /app\.asana\.com/, + service: 'asana', + canCreate: true, + }, + { + key: 'scopingDocId', + urlKey: 'scopingDocUrl', + label: 'Scoping Document', + placeholder: 'https://docs.google.com/document/d/...', + urlPattern: /docs\.google\.com\/document/, + service: 'google', + canCreate: true, + }, + { + key: 'kickoffDeckId', + urlKey: 'kickoffDeckUrl', + label: 'Kickoff Deck', + placeholder: 'https://docs.google.com/presentation/d/...', + urlPattern: /docs\.google\.com\/presentation/, + service: 'google', + canCreate: true, + }, + { + key: 'googleFolderId', + urlKey: 'folderUrl', + label: 'Google Drive Folder', + placeholder: 'https://drive.google.com/drive/folders/...', + urlPattern: /drive\.google\.com/, + service: 'google', + canCreate: true, + }, +]; + +interface ResourceManagementProps { + createdResources: CreatedResources; + connectionStatus: ConnectionStatus; + airtableProjectId: string | undefined; + onLinkResource: (resourceKey: keyof CreatedResources, url: string) => Promise; + onCreateResource: (resourceType: 'asana' | 'scopingDoc' | 'kickoffDeck' | 'folder') => Promise; + onSyncMilestones?: () => Promise; + isLoading: boolean; +} + +interface ResourceRowProps { + config: ResourceConfig; + url: string | undefined; + isConnected: boolean; + hasAirtableRecord: boolean; + onLink: (url: string) => Promise; + onCreate: () => Promise; + isLoading: boolean; + loadingType: string | null; +} + +function ResourceRow({ + config, + url, + isConnected, + hasAirtableRecord, + onLink, + onCreate, + isLoading, + loadingType, +}: ResourceRowProps) { + const [isLinking, setIsLinking] = useState(false); + const [linkUrl, setLinkUrl] = useState(''); + const [error, setError] = useState(null); + + const hasResource = !!url; + const isThisLoading = loadingType === config.key; + + const handleSubmitLink = async () => { + if (!linkUrl.trim()) { + setError('Please enter a URL'); + return; + } + if (!config.urlPattern.test(linkUrl)) { + setError(`Please enter a valid ${config.label} URL`); + return; + } + + setError(null); + try { + await onLink(linkUrl); + setIsLinking(false); + setLinkUrl(''); + } catch (err) { + setError((err as Error).message); + } + }; + + // Resource already exists + if (hasResource) { + return ( +
+ + {config.label} + + View + +
+ ); + } + + // Service not connected + if (!isConnected) { + return ( +
+ + {config.label} + Not connected +
+ ); + } + + // Linking mode + if (isLinking) { + return ( +
+
+ + {config.label} +
+
+ setLinkUrl(e.target.value)} + placeholder={config.placeholder} + className="flex-1 px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" + disabled={isLoading} + /> + + +
+ {error &&

{error}

} +
+ ); + } + + // Resource missing - show action buttons + return ( +
+ + {config.label} +
+ {hasAirtableRecord && ( + + )} + {config.canCreate && hasAirtableRecord && ( + + )} +
+
+ ); +} + +export default function ResourceManagement({ + createdResources, + connectionStatus, + airtableProjectId, + onLinkResource, + onCreateResource, + onSyncMilestones, + isLoading, +}: ResourceManagementProps) { + const [loadingType, setLoadingType] = useState(null); + + const hasAirtableRecord = !!airtableProjectId; + const hasAsanaProject = !!createdResources.asanaProjectGid; + const milestonesCreated = !!createdResources.asanaMilestonesCreated; + + // Don't show if no Airtable record exists + if (!hasAirtableRecord) { + return null; + } + + // Check if all resources are created + const allResourcesCreated = RESOURCE_CONFIGS.every( + (config) => !!createdResources[config.urlKey] || !connectionStatus[config.service] + ); + + // Check if milestones need attention + const milestonesNeedSync = hasAsanaProject && !milestonesCreated && connectionStatus.asana; + + // Don't show if everything is complete (resources and milestones) + if (allResourcesCreated && !milestonesNeedSync) { + return null; + } + + const handleLink = async (config: ResourceConfig, url: string) => { + setLoadingType(config.key); + try { + await onLinkResource(config.urlKey, url); + } finally { + setLoadingType(null); + } + }; + + const handleCreate = async (config: ResourceConfig) => { + setLoadingType(config.key); + try { + const typeMap: Record = { + asanaProjectGid: 'asana', + scopingDocId: 'scopingDoc', + kickoffDeckId: 'kickoffDeck', + googleFolderId: 'folder', + }; + const resourceType = typeMap[config.key]; + if (resourceType) { + await onCreateResource(resourceType); + } + } finally { + setLoadingType(null); + } + }; + + const handleSyncMilestones = async () => { + if (!onSyncMilestones) return; + setLoadingType('milestones'); + try { + await onSyncMilestones(); + } finally { + setLoadingType(null); + } + }; + + return ( +
+

Manage Resources

+

+ Link existing resources or create missing ones for this project. +

+
+ {RESOURCE_CONFIGS.map((config) => ( + handleLink(config, url)} + onCreate={() => handleCreate(config)} + isLoading={isLoading || loadingType !== null} + loadingType={loadingType} + /> + ))} + + {/* Asana Milestones Section */} + {hasAsanaProject && connectionStatus.asana && ( +
+ {milestonesCreated ? ( + <> + + Asana Milestones + Synced + + ) : ( + <> + + Asana Milestones + {onSyncMilestones && ( + + )} + + )} +
+ )} +
+
+ ); +} diff --git a/project-creation-app/src/config/fields.toml b/project-creation-app/src/config/fields.toml index 73e1b6e..cc0aad9 100644 --- a/project-creation-app/src/config/fields.toml +++ b/project-creation-app/src/config/fields.toml @@ -76,6 +76,28 @@ section = "basics" airtable_field = "Project Type" options = ["Project", "Report", "Convening", "Standard Project"] +# ----------------------------------------------------------------------------- +# EXISTING RESOURCE LINKS (optional - if resources already exist) +# ----------------------------------------------------------------------------- + +[fields.existing_scoping_doc_url] +type = "url" +label = "Existing Scoping Doc URL" +required = false +placeholder = "https://docs.google.com/document/d/..." +section = "basics" +help_text = "If a scoping document already exists, paste its URL here to skip creation" +validation_pattern = "docs\\.google\\.com/document/d/" + +[fields.existing_asana_url] +type = "url" +label = "Existing Asana Board URL" +required = false +placeholder = "https://app.asana.com/..." +section = "basics" +help_text = "If an Asana project already exists, paste its URL here to skip creation" +validation_pattern = "app\\.asana\\.com/" + # ============================================================================= # DESCRIPTION FIELDS # ============================================================================= @@ -206,6 +228,14 @@ drafts = "Project Drafts" funders = "Funders" parent_initiatives = "Parent Initiatives" +# Table IDs for URL construction (find in Airtable URL: airtable.com/{baseId}/{tableId}/{viewId}/{recordId}) +[airtable.table_ids] +projects = "tblV9NkWJhFScRsbx" + +# View IDs for URL construction (the default view to open records in) +[airtable.view_ids] +projects = "viwNL0a6e0n7QkN8Z" + [airtable.team_members_fields] # Fields to fetch from team members table name = "Full Name" diff --git a/project-creation-app/src/config/index.ts b/project-creation-app/src/config/index.ts index 3fc7c7a..4ec23ca 100644 --- a/project-creation-app/src/config/index.ts +++ b/project-creation-app/src/config/index.ts @@ -7,6 +7,8 @@ import type { Config, AirtableConfig } from '../types'; interface ParsedConfig extends Config { airtable: AirtableConfig & { tables: Record; + table_ids: Record; + view_ids: Record; project_fields: Record; milestone_fields: Record; assignment_fields: Record; @@ -51,6 +53,8 @@ try { // Airtable configuration export const airtableConfig = parsedConfig.airtable || {}; export const airtableTables = airtableConfig.tables || {}; +export const airtableTableIds = airtableConfig.table_ids || {}; +export const airtableViewIds = airtableConfig.view_ids || {}; export const airtableProjectFields = airtableConfig.project_fields || {}; export const airtableMilestoneFields = airtableConfig.milestone_fields || {}; export const airtableAssignmentFields = airtableConfig.assignment_fields || {}; diff --git a/project-creation-app/src/config/integrations.toml b/project-creation-app/src/config/integrations.toml index 5fa132e..d743565 100644 --- a/project-creation-app/src/config/integrations.toml +++ b/project-creation-app/src/config/integrations.toml @@ -6,15 +6,20 @@ relay_url_env = "VITE_OAUTH_RELAY_URL" [airtable] base_id_env = "VITE_AIRTABLE_BASE_ID" -# Tables + +# Tables - names used for API calls projects_table = "Projects" milestones_table = "Milestones" assignments_table = "Assignments" team_members_table = "Data Team Members" +# Table IDs - used for constructing URLs (find in Airtable URL when viewing a record) +# Format: https://airtable.com/{baseId}/{tableId}/{viewId}/{recordId} +projects_table_id = "" # e.g., "tblXXXXXXXXXXXXXX" + [asana] team_gid_env = "VITE_ASANA_TEAM_GID" -workspace_gid = "1206584297698339" +workspace_gid = "671639272801549" # Default template for unspecified project types default_template_gid = "1209652649504377" @@ -26,6 +31,7 @@ default_template_gid = "1209652649504377" # Example: Report and Convening have specific templates # Report = "REPORT_TEMPLATE_GID_HERE" # Convening = "CONVENING_TEMPLATE_GID_HERE" +Product = "1212543818793984" [google] # Template document/presentation IDs @@ -34,3 +40,46 @@ kickoff_deck_template_id_env = "VITE_GOOGLE_KICKOFF_DECK_TEMPLATE_ID" # Shared Drive location shared_drive_id_env = "VITE_GOOGLE_SHARED_DRIVE_ID" parent_folder_id_env = "VITE_GOOGLE_PARENT_FOLDER_ID" + +# ============================================================================= +# Duplicate Detection & Resolution +# ============================================================================= + +[duplicates] +# Enable duplicate checking before project creation +enabled = true + +# Default resolution strategies when duplicates are found +# These are pre-selected in the resolution modal; users can override +[duplicates.defaults] +airtable = "update" # "update" | "create_new" +asana = "use_existing" # "use_existing" | "create_new" +google = "keep" # "keep" | "skip" | "recreate" + +[duplicates.airtable] +# Fields to update when using "update" strategy +# Scalar fields are overwritten; linked records (milestones, assignments) are merged +update_fields = [ + "description", + "objectives", + "start_date", + "end_date", + "funder", + "parent_initiative", + "project_type" +] +# Merge milestones by matching on name +merge_milestones = true +# Merge assignments by matching on role +merge_assignments = true + +[duplicates.asana] +# When using existing project, update milestones that match by name +update_milestones = true +# Sync project members when using existing project +sync_members = true + +[duplicates.google] +# Allow the destructive "recreate" option in the UI +# When false, only "keep" and "skip" are shown +allow_recreate = false diff --git a/project-creation-app/src/hooks/useConfig.ts b/project-creation-app/src/hooks/useConfig.ts index e51e0ef..e4b7c88 100644 --- a/project-creation-app/src/hooks/useConfig.ts +++ b/project-creation-app/src/hooks/useConfig.ts @@ -13,6 +13,27 @@ interface ConfigHookResult { isLoading: boolean; } +interface DuplicatesConfig { + enabled?: boolean; + defaults?: { + airtable?: 'update' | 'create_new'; + asana?: 'use_existing' | 'create_new'; + google?: 'keep' | 'skip' | 'recreate'; + }; + airtable?: { + update_fields?: string[]; + merge_milestones?: boolean; + merge_assignments?: boolean; + }; + asana?: { + update_milestones?: boolean; + sync_members?: boolean; + }; + google?: { + allow_recreate?: boolean; + }; +} + interface IntegrationsConfig { asana?: { workspace_gid?: string; @@ -26,8 +47,11 @@ interface IntegrationsConfig { scoping_doc_template_id?: string; kickoff_deck_template_id?: string; }; + duplicates?: DuplicatesConfig; } +export type { DuplicatesConfig, IntegrationsConfig }; + interface FieldWithName extends FieldConfig { name: string; } diff --git a/project-creation-app/src/hooks/useDuplicateCheck.ts b/project-creation-app/src/hooks/useDuplicateCheck.ts new file mode 100644 index 0000000..7c92cea --- /dev/null +++ b/project-creation-app/src/hooks/useDuplicateCheck.ts @@ -0,0 +1,150 @@ +// Hook for checking duplicates across platforms before project creation + +import { useState, useCallback, useMemo } from 'react'; +import { useIntegrationsConfig } from './useConfig'; +import { + checkAllDuplicates, + getDefaultResolutions, + isDuplicateCheckEnabled, + type CheckAllDuplicatesParams, +} from '../services/duplicates'; +import type { + DuplicateCheckResult, + DuplicateResolution, + DuplicateDefaults, +} from '../types'; + +interface UseDuplicateCheckResult { + /** Trigger a duplicate check for the given project name */ + checkDuplicates: (params: CheckAllDuplicatesParams) => Promise; + /** Most recent duplicate check result */ + result: DuplicateCheckResult | null; + /** Whether a check is currently in progress */ + isChecking: boolean; + /** Error from the most recent check, if any */ + error: Error | null; + /** Reset the check state (clear results) */ + reset: () => void; + /** Whether duplicate checking is enabled in config */ + isEnabled: boolean; + /** Default resolution strategies from config */ + defaults: DuplicateDefaults; + /** Current user resolution choices (can be modified) */ + resolution: DuplicateResolution; + /** Update resolution for a specific platform */ + setResolution: (resolution: Partial) => void; + /** Reset resolution to defaults */ + resetResolution: () => void; +} + +/** + * Hook for managing duplicate detection workflow + * + * Usage: + * ```tsx + * const { + * checkDuplicates, + * result, + * isChecking, + * resolution, + * setResolution, + * } = useDuplicateCheck(); + * + * // Trigger check when user submits + * const handleSubmit = async () => { + * const duplicates = await checkDuplicates({ projectName, workspaceGid }); + * if (duplicates.hasDuplicates) { + * // Show resolution modal + * } else { + * // Proceed with creation + * } + * }; + * ``` + */ +export function useDuplicateCheck(): UseDuplicateCheckResult { + const { config } = useIntegrationsConfig(); + + const isEnabled = isDuplicateCheckEnabled(config?.duplicates); + + // Memoize defaults to prevent useEffect in modal from resetting user choices on every render + const duplicatesConfig = config?.duplicates; + const defaults = useMemo( + () => getDefaultResolutions(duplicatesConfig), + [duplicatesConfig?.defaults?.airtable, duplicatesConfig?.defaults?.asana, duplicatesConfig?.defaults?.google] + ); + + const [result, setResult] = useState(null); + const [isChecking, setIsChecking] = useState(false); + const [error, setError] = useState(null); + const [resolution, setResolutionState] = useState({ ...defaults }); + + const checkDuplicates = useCallback( + async (params: CheckAllDuplicatesParams): Promise => { + setIsChecking(true); + setError(null); + + try { + const checkResult = await checkAllDuplicates(params); + setResult(checkResult); + return checkResult; + } catch (err) { + const error = err instanceof Error ? err : new Error('Duplicate check failed'); + setError(error); + // Return empty result on error - allow creation to proceed + const emptyResult: DuplicateCheckResult = { + airtable: { found: false }, + asana: { found: false }, + google: { found: false }, + hasDuplicates: false, + checkedAt: Date.now(), + }; + setResult(emptyResult); + return emptyResult; + } finally { + setIsChecking(false); + } + }, + [] + ); + + const reset = useCallback(() => { + setResult(null); + setError(null); + setIsChecking(false); + }, []); + + const setResolution = useCallback((partial: Partial) => { + setResolutionState(prev => ({ ...prev, ...partial })); + }, []); + + const resetResolution = useCallback(() => { + setResolutionState({ ...defaults }); + }, [defaults]); + + return { + checkDuplicates, + result, + isChecking, + error, + reset, + isEnabled, + defaults, + resolution, + setResolution, + resetResolution, + }; +} + +/** + * Helper to determine if we should show the resolution modal + */ +export function shouldShowResolutionModal( + result: DuplicateCheckResult | null, + isEnabled: boolean +): boolean { + if (!isEnabled) return false; + if (!result) return false; + return result.hasDuplicates; +} + +export default useDuplicateCheck; diff --git a/project-creation-app/src/hooks/useProjectTypes.ts b/project-creation-app/src/hooks/useProjectTypes.ts new file mode 100644 index 0000000..ded2250 --- /dev/null +++ b/project-creation-app/src/hooks/useProjectTypes.ts @@ -0,0 +1,39 @@ +// Hook for fetching project type options from Airtable +import { useQuery } from '@tanstack/react-query'; +import { getProjectTypeOptions } from '../services/airtable'; +import { tokenManager } from '../services/oauth'; +import { useFieldsConfig } from './useConfig'; + +interface UseProjectTypesResult { + data: string[] | undefined; + isLoading: boolean; + error: Error | null; + isConnected: boolean; +} + +export function useProjectTypes(): UseProjectTypesResult { + const { config } = useFieldsConfig(); + + // Get static options from config as fallback + const staticOptions = (config?.fields?.project_type?.options as string[]) || []; + + const isConnected = tokenManager.isTokenValid('airtable'); + + const query = useQuery({ + queryKey: ['projectTypes'], + queryFn: getProjectTypeOptions, + enabled: isConnected, + staleTime: 10 * 60 * 1000, // Cache for 10 minutes (schema changes rarely) + retry: false, // Don't retry on auth errors + }); + + // Return dynamic options if available, fall back to static config + return { + data: query.data && query.data.length > 0 ? query.data : staticOptions, + isLoading: query.isLoading, + error: query.error, + isConnected, + }; +} + +export default useProjectTypes; diff --git a/project-creation-app/src/pages/ProjectForm.tsx b/project-creation-app/src/pages/ProjectForm.tsx index 86a2f55..f2b62d0 100644 --- a/project-creation-app/src/pages/ProjectForm.tsx +++ b/project-creation-app/src/pages/ProjectForm.tsx @@ -3,6 +3,10 @@ import { useForm, useFieldArray } from 'react-hook-form'; import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import Header from '../components/layout/Header'; import ShareDraftModal from '../components/ui/ShareDraftModal'; +import DuplicateResolutionModal from '../components/ui/DuplicateResolutionModal'; +import ProjectSearch from '../components/ui/ProjectSearch'; +import ProjectPreviewModal, { type PopulateFormData } from '../components/ui/ProjectPreviewModal'; +import ResourceManagement from '../components/ui/ResourceManagement'; import { FormSection, FormField, @@ -10,14 +14,17 @@ import { ROLE_TYPES, DEFAULT_FORM_VALUES, } from '../components/form/FormComponents'; +import { airtableRoleValues } from '../config'; import { useTeamMembers } from '../hooks/useTeamMembers'; import { useFunders } from '../hooks/useFunders'; import { useParentInitiatives } from '../hooks/useParentInitiatives'; -import { useFieldsConfig } from '../hooks/useConfig'; +import { useProjectTypes } from '../hooks/useProjectTypes'; +import { useFieldsConfig, useIntegrationsConfig } from '../hooks/useConfig'; +import { useDuplicateCheck } from '../hooks/useDuplicateCheck'; import { useAsanaTemplateGid } from '../utils/asanaTemplates'; import { getConnectionStatus, userManager } from '../services/oauth'; import * as airtable from '../services/airtable'; -import { airtableProjectFields, airtableTables } from '../services/airtable'; +import { airtableProjectFields, airtableTables, getRecordUrl } from '../services/airtable'; import * as asana from '../services/asana'; import * as google from '../services/google'; import * as drafts from '../services/drafts'; @@ -31,8 +38,10 @@ import { DocumentDuplicateIcon, PaperAirplaneIcon, ClipboardDocumentListIcon, + RocketLaunchIcon, + MagnifyingGlassIcon, } from '@heroicons/react/24/outline'; -import type { FormData, CreatedResources, ConnectionStatus, DraftStatus, RoleAssignment } from '../types'; +import type { FormData, CreatedResources, ConnectionStatus, DraftStatus, RoleAssignment, DuplicateResolution } from '../types'; import type { AsanaUser, RoleAssignment as AsanaRoleAssignment } from '../services/asana'; // Local storage keys for form state persistence @@ -72,7 +81,7 @@ function ProgressIndicator({ sections, currentSection }: ProgressIndicatorProps) ); } -type LoadingActionType = 'asanaBoard' | 'asanaMilestones' | 'scopingDoc' | 'kickoffDeck' | 'airtable' | 'saveDraft' | null; +type LoadingActionType = 'asanaBoard' | 'asanaMilestones' | 'scopingDoc' | 'kickoffDeck' | 'airtable' | 'saveDraft' | 'duplicateCheck' | 'createAll' | null; interface DraftMessage { type: 'success' | 'error'; @@ -102,6 +111,15 @@ export default function ProjectForm() { const [isShareLoading, setIsShareLoading] = useState(false); const [draftMessage, setDraftMessage] = useState(null); + // Duplicate detection state + const [duplicateModalOpen, setDuplicateModalOpen] = useState(false); + const [pendingResolution, setPendingResolution] = useState(null); + + // Existing project state (Phase 2) + const [selectedProjectId, setSelectedProjectId] = useState(null); + const [previewModalOpen, setPreviewModalOpen] = useState(false); + const [editingExistingProject, setEditingExistingProject] = useState(null); + // Track created resources const [createdResources, setCreatedResources] = useState(() => { const saved = localStorage.getItem(CREATED_RESOURCES_KEY); @@ -155,8 +173,19 @@ export default function ProjectForm() { const { data: teamMembers = [], isLoading: loadingMembers } = useTeamMembers(); const { data: funders = [] } = useFunders(); const { data: parentInitiatives = [] } = useParentInitiatives(); + const { data: projectTypeOptions = [] } = useProjectTypes(); const { config } = useFieldsConfig(); - const projectTypeOptions = (config?.fields?.project_type?.options as string[]) || []; + const { config: integrationsConfig } = useIntegrationsConfig(); + + // Duplicate detection hook + const { + checkDuplicates, + result: duplicateResult, + isChecking: isCheckingDuplicates, + isEnabled: duplicateCheckEnabled, + defaults: duplicateDefaults, + } = useDuplicateCheck(); + // Form setup with draft restoration const { @@ -164,21 +193,19 @@ export default function ProjectForm() { control, watch, setValue, + getValues, formState: { errors, isDirty }, } = useForm({ defaultValues: DEFAULT_FORM_VALUES, }); // Outcomes field array - const { fields: outcomeFields, append: addOutcome, remove: removeOutcome } = useFieldArray({ + const { fields: outcomeFields, append: addOutcome, remove: removeOutcome, replace: replaceOutcomes } = useFieldArray({ control, name: 'outcomes', }); - // Watch form values for draft saving - const watchedValues = watch(); - - // Get dynamic Asana template based on project type + // Watch only specific fields that need reactivity (NOT all fields) const watchedProjectType = watch('projectType'); const asanaTemplateGid = useAsanaTemplateGid(watchedProjectType); @@ -197,16 +224,25 @@ export default function ProjectForm() { } }, [setValue]); - // Save draft on changes (debounced) + // Save draft on changes (using watch subscription to avoid re-renders) useEffect(() => { - if (!isDirty) return; + let timeoutId: ReturnType; - const timeout = setTimeout(() => { - localStorage.setItem(DRAFT_KEY, JSON.stringify(watchedValues)); - }, 1000); + const subscription = watch((formValues) => { + // Clear any pending save + if (timeoutId) clearTimeout(timeoutId); + + // Debounce save to localStorage + timeoutId = setTimeout(() => { + localStorage.setItem(DRAFT_KEY, JSON.stringify(formValues)); + }, 1000); + }); - return () => clearTimeout(timeout); - }, [watchedValues, isDirty]); + return () => { + subscription.unsubscribe(); + if (timeoutId) clearTimeout(timeoutId); + }; + }, [watch]); // Track current section based on scroll useEffect(() => { @@ -231,8 +267,8 @@ export default function ProjectForm() { return () => observer.disconnect(); }, []); - // Get current form data - const getFormData = (): FormData => watchedValues; + // Get current form data (use getValues for on-demand access without re-renders) + const getFormData = (): FormData => getValues(); // === INDIVIDUAL ACTION HANDLERS === @@ -565,7 +601,7 @@ export default function ProjectForm() { await airtable.updateRecord(tableName, projectId, urlUpdates); } - const airtableUrl = `https://airtable.com/${import.meta.env.VITE_AIRTABLE_BASE_ID}/${projectId}`; + const airtableUrl = getRecordUrl(projectId, 'projects'); updateCreatedResources({ airtableProjectId: projectId, airtableUrl }); } catch (err) { setSubmitError(`Airtable: ${(err as Error).message}`); @@ -577,9 +613,10 @@ export default function ProjectForm() { // Navigate to success page const handleFinish = (): void => { localStorage.removeItem(DRAFT_KEY); + const data = getFormData(); navigate('/success', { state: { - projectName: watchedValues.projectName, + projectName: data.projectName, airtableUrl: createdResources.airtableUrl, asanaUrl: createdResources.asanaUrl, driveUrl: createdResources.folderUrl, @@ -588,6 +625,658 @@ export default function ProjectForm() { }); }; + // === POST-SUBMISSION RESOURCE MANAGEMENT (Phase 4) === + + // Link an existing resource URL to the Airtable record + const handleLinkResource = async (resourceKey: keyof CreatedResources, url: string): Promise => { + if (!createdResources.airtableProjectId) { + throw new Error('No Airtable record to link to'); + } + + // Map resource keys to Airtable field names + const fieldMap: Partial> = { + asanaUrl: airtableProjectFields.asana_url || 'Asana Board', + scopingDocUrl: airtableProjectFields.scoping_doc_url || 'Scoping Doc', + kickoffDeckUrl: airtableProjectFields.kickoff_deck_url || 'Kickoff Deck', + folderUrl: airtableProjectFields.folder_url || 'Project Folder', + }; + + const fieldName = fieldMap[resourceKey]; + if (!fieldName) { + throw new Error(`Cannot link resource type: ${resourceKey}`); + } + + // Update Airtable record + const tableName = airtableTables.projects || 'Projects'; + await airtable.updateRecord(tableName, createdResources.airtableProjectId, { + [fieldName]: url, + }); + + // Update local state + updateCreatedResources({ [resourceKey]: url }); + + debugLogger.log('form', 'Linked resource to Airtable', { resourceKey, url }); + }; + + // Create an individual resource after initial submission + const handleCreateIndividualResource = async ( + resourceType: 'asana' | 'scopingDoc' | 'kickoffDeck' | 'folder' + ): Promise => { + const data = getFormData(); + + switch (resourceType) { + case 'asana': + await handleCreateAsanaBoard(); + // Update Airtable with new Asana URL + if (createdResources.asanaUrl && createdResources.airtableProjectId) { + const tableName = airtableTables.projects || 'Projects'; + await airtable.updateRecord(tableName, createdResources.airtableProjectId, { + [airtableProjectFields.asana_url || 'Asana Board']: createdResources.asanaUrl, + }); + } + break; + + case 'scopingDoc': + await handleCreateScopingDoc(); + // Update Airtable with new Scoping Doc URL + if (createdResources.scopingDocUrl && createdResources.airtableProjectId) { + const tableName = airtableTables.projects || 'Projects'; + await airtable.updateRecord(tableName, createdResources.airtableProjectId, { + [airtableProjectFields.scoping_doc_url || 'Scoping Doc']: createdResources.scopingDocUrl, + }); + } + break; + + case 'kickoffDeck': + await handleCreateKickoffDeck(); + // Update Airtable with new Kickoff Deck URL + if (createdResources.kickoffDeckUrl && createdResources.airtableProjectId) { + const tableName = airtableTables.projects || 'Projects'; + await airtable.updateRecord(tableName, createdResources.airtableProjectId, { + [airtableProjectFields.kickoff_deck_url || 'Kickoff Deck']: createdResources.kickoffDeckUrl, + }); + } + break; + + case 'folder': + // Create just the folder + const sharedDriveId = import.meta.env.VITE_GOOGLE_SHARED_DRIVE_ID; + const parentFolderId = import.meta.env.VITE_GOOGLE_PROJECTS_FOLDER_ID; + const folderName = data.projectAcronym || data.projectName; + + const newFolder = await google.createDriveFolder(folderName, sharedDriveId, parentFolderId); + const folderUrl = google.getFolderUrl(newFolder.id); + updateCreatedResources({ googleFolderId: newFolder.id, folderUrl }); + + // Update Airtable + if (createdResources.airtableProjectId) { + const tableName = airtableTables.projects || 'Projects'; + await airtable.updateRecord(tableName, createdResources.airtableProjectId, { + [airtableProjectFields.folder_url || 'Project Folder']: folderUrl, + }); + } + break; + } + }; + + // Sync milestones to Asana (for post-facto creation) + const handleSyncMilestones = async (): Promise => { + if (!createdResources.asanaProjectGid) { + setSubmitError('No Asana project found. Please create or link an Asana project first.'); + return; + } + + setLoadingAction('syncMilestones'); + setSubmitError(null); + + try { + const data = getFormData(); + const validOutcomes = data.outcomes.filter(o => o.name?.trim()); + + if (validOutcomes.length === 0) { + setSubmitError('No milestones to sync. Please add outcomes first.'); + return; + } + + // Get Asana users for assignee matching + const asanaUsers = await asana.getWorkspaceUsers( + integrationsConfig?.asana?.workspace_gid || import.meta.env.VITE_ASANA_WORKSPACE_GID + ); + + // Get project coordinator member ID for default assignment + const projectCoordinatorId = data.roles.project_coordinator?.memberId; + + // Helper to find Asana user GID + const findUserGid = (memberId: string | undefined): string | null => { + if (!memberId) return null; + const member = teamMembers.find((m) => m.id === memberId); + if (!member) return null; + const match = asana.findBestUserMatch(member.name, asanaUsers); + return match?.user.gid || null; + }; + + for (const outcome of validOutcomes) { + // Use specific assignee if set, otherwise default to project coordinator + const assigneeMemberId = outcome.assignee || projectCoordinatorId; + const assigneeGid = findUserGid(assigneeMemberId); + + await asana.createTask( + createdResources.asanaProjectGid, + { + name: outcome.name, + description: outcome.description, + dueDate: outcome.dueDate, + }, + assigneeGid + ); + } + + updateCreatedResources({ asanaMilestonesCreated: true }); + debugLogger.log('form', 'Milestones synced to Asana', { count: validOutcomes.length }); + } catch (err) { + setSubmitError(`Milestone sync failed: ${(err as Error).message}`); + debugLogger.log('error', 'Milestone sync failed', err); + } finally { + setLoadingAction(null); + } + }; + + // === CREATE ALL RESOURCES (with duplicate check) === + + // Create all resources in sequence after duplicate resolution + const executeCreateAll = async (resolution?: DuplicateResolution): Promise => { + setLoadingAction('createAll'); + setSubmitError(null); + + try { + const data = getFormData(); + + // For now, we proceed with creation regardless of resolution + // Future phases will implement update vs. create logic based on resolution + // Currently just logs the resolution for debugging + if (resolution) { + debugLogger.log('form', 'Creating resources with resolution', resolution); + } + + // Check for existing URLs provided by user + const hasExistingAsana = !!data.existingAsanaUrl; + const hasExistingScopingDoc = !!data.existingScopingDocUrl; + + // Check for skip resolutions + const skipAsana = resolution?.asana === 'skip'; + const skipGoogle = resolution?.google === 'skip'; + const skipAirtable = resolution?.airtable === 'skip'; + + // Track newly created resource IDs locally (React state updates are async) + let newAsanaProjectGid: string | undefined; + let newAirtableProjectId: string | undefined; + let newScopingDocUrl: string | undefined; + let newFolderUrl: string | undefined; + let asanaUsers: AsanaUser[] | null = null; + + // Helper to get Asana users (fetch once and cache) + const getAsanaUsers = async (): Promise => { + if (!asanaUsers) { + asanaUsers = await asana.getWorkspaceUsers( + integrationsConfig?.asana?.workspace_gid || import.meta.env.VITE_ASANA_WORKSPACE_GID + ); + } + return asanaUsers; + }; + + // Helper to find Asana user GID for a team member + const findAsanaUserGid = async (memberId: string | undefined): Promise => { + if (!memberId) return null; + const member = teamMembers.find((m) => m.id === memberId); + if (!member) return null; + const users = await getAsanaUsers(); + const match = asana.findBestUserMatch(member.name, users); + return match?.user.gid || null; + }; + + // If user provided existing URLs, use them instead of creating + if (hasExistingAsana && !createdResources.asanaUrl) { + debugLogger.log('form', 'Using existing Asana URL from form', data.existingAsanaUrl); + updateCreatedResources({ asanaUrl: data.existingAsanaUrl }); + } + if (hasExistingScopingDoc && !createdResources.scopingDocUrl) { + debugLogger.log('form', 'Using existing Scoping Doc URL from form', data.existingScopingDocUrl); + updateCreatedResources({ scopingDocUrl: data.existingScopingDocUrl }); + } + + // 1. Create Asana Board (if connected, not skipped, and no existing URL) + if (connectionStatus.asana && !createdResources.asanaProjectGid && !hasExistingAsana && !skipAsana) { + try { + const users = await getAsanaUsers(); + + const roleAssignments: AsanaRoleAssignment[] = []; + for (const [roleName, assignment] of Object.entries(data.roles)) { + if (assignment?.memberId) { + const member = teamMembers.find((m) => m.id === assignment.memberId); + if (member) { + const match = asana.findBestUserMatch(member.name, users); + if (match) { + roleAssignments.push({ roleName, userGid: match.user.gid }); + } + } + } + } + + newAsanaProjectGid = await asana.createProjectFromTemplate( + templateGid || import.meta.env.VITE_ASANA_TEMPLATE_GID, + data.projectName, + import.meta.env.VITE_ASANA_TEAM_GID, + data.startDate, + roleAssignments + ); + + const asanaUrl = asana.getProjectUrl(newAsanaProjectGid); + updateCreatedResources({ asanaProjectGid: newAsanaProjectGid, asanaUrl }); + } catch (err) { + debugLogger.log('error', 'Asana Board creation failed', err); + // Continue with other resources + } + } + + // 2. Create Asana Milestones + // Use newAsanaProjectGid (just created) or createdResources.asanaProjectGid (from previous run) + const asanaProjectGidForMilestones = newAsanaProjectGid || createdResources.asanaProjectGid; + if (connectionStatus.asana && asanaProjectGidForMilestones && !createdResources.asanaMilestonesCreated) { + try { + // Get project coordinator member ID for default assignment + const projectCoordinatorId = data.roles.project_coordinator?.memberId; + + for (const outcome of data.outcomes) { + if (outcome.name) { + // Use specific assignee if set, otherwise default to project coordinator + const assigneeMemberId = outcome.assignee || projectCoordinatorId; + const assigneeGid = await findAsanaUserGid(assigneeMemberId); + + await asana.createTask( + asanaProjectGidForMilestones, + { + name: outcome.name, + description: outcome.description, + dueDate: outcome.dueDate, + }, + assigneeGid + ); + } + } + updateCreatedResources({ asanaMilestonesCreated: true }); + } catch (err) { + debugLogger.log('error', 'Asana Milestones creation failed', err); + } + } + + // 3. Create Google Drive Folder and Documents (skip if user provided existing scoping doc or chose to skip) + if (connectionStatus.google && !createdResources.googleFolderId && !hasExistingScopingDoc && !skipGoogle) { + try { + const sharedDriveId = import.meta.env.VITE_GOOGLE_SHARED_DRIVE_ID; + const parentFolderId = import.meta.env.VITE_GOOGLE_PARENT_FOLDER_ID; + + const folderId = await google.createDriveFolder( + data.projectName, + sharedDriveId, + parentFolderId + ); + newFolderUrl = google.getFolderUrl(folderId); + updateCreatedResources({ + googleFolderId: folderId, + folderUrl: newFolderUrl, + }); + + // Create Scoping Doc (only if user didn't provide existing URL) + const scopingTemplateId = import.meta.env.VITE_GOOGLE_SCOPING_TEMPLATE_ID; + if (scopingTemplateId && !hasExistingScopingDoc) { + const scopingDoc = await google.copyTemplate( + scopingTemplateId, + folderId, + `${data.projectName} - Scoping Document` + ); + const replacements = google.buildReplacements(data); + await google.populateDocWithTables(scopingDoc.id, data, teamMembers); + await google.populateDoc(scopingDoc.id, replacements); + newScopingDocUrl = google.getDocUrl(scopingDoc.id); + updateCreatedResources({ + scopingDocId: scopingDoc.id, + scopingDocUrl: newScopingDocUrl, + }); + } + + // Create Kickoff Deck + const kickoffTemplateId = import.meta.env.VITE_GOOGLE_KICKOFF_DECK_TEMPLATE_ID; + if (kickoffTemplateId) { + const kickoffDeck = await google.copyTemplate( + kickoffTemplateId, + folderId, + `${data.projectName} - Kickoff Deck` + ); + const replacements = google.buildReplacements(data); + await google.populateSlides(kickoffDeck.id, replacements); + updateCreatedResources({ + kickoffDeckId: kickoffDeck.id, + kickoffDeckUrl: google.getSlidesUrl(kickoffDeck.id), + }); + } + } catch (err) { + debugLogger.log('error', 'Google Drive creation failed', err); + } + } + + // 4. Create or Update Airtable Records + const shouldUpdateAirtable = resolution?.airtable === 'update' && duplicateResult?.airtable?.record?.id; + const existingAirtableId = duplicateResult?.airtable?.record?.id; + + if (connectionStatus.airtable && !skipAirtable) { + try { + const f = airtableProjectFields; + const tableName = airtableTables.projects || 'Projects'; + + if (shouldUpdateAirtable && existingAirtableId) { + // UPDATE existing record + debugLogger.log('form', 'Updating existing Airtable record', { existingAirtableId }); + + const updateFields: Record = { + [f.name || 'Project']: data.projectName, + [f.acronym || 'Project Acronym']: data.projectAcronym || '', + [f.description || 'Project Description']: data.description || '', + [f.objectives || 'Objectives']: data.objectives || '', + [f.start_date || 'Start Date']: data.startDate || null, + [f.end_date || 'End Date']: data.endDate || null, + }; + + // Add optional fields + if (data.funder) { + updateFields[f.funder || 'Funder'] = [data.funder]; + } + if (data.parentInitiative) { + updateFields[f.parent_initiative || 'Parent Initiative'] = [data.parentInitiative]; + } + if (data.projectType) { + updateFields[f.project_type || 'Project Type'] = data.projectType; + } + + await airtable.updateRecord(tableName, existingAirtableId, updateFields); + newAirtableProjectId = existingAirtableId; + + // TODO: Merge milestones and assignments (for now, just note this) + debugLogger.log('form', 'Note: Milestone/assignment merging not yet implemented'); + + const airtableUrl = getRecordUrl(existingAirtableId, 'projects'); + updateCreatedResources({ airtableProjectId: existingAirtableId, airtableUrl }); + + } else if (!createdResources.airtableProjectId) { + // CREATE new record + debugLogger.log('form', 'Creating new Airtable record'); + + const project = await airtable.createProject({ + name: data.projectName, + acronym: data.projectAcronym, + description: data.description, + objectives: data.objectives, + startDate: data.startDate, + endDate: data.endDate, + funder: data.funder, + parentInitiative: data.parentInitiative, + projectType: data.projectType, + }); + + newAirtableProjectId = project.id; + + // Create milestones + const milestones = data.outcomes + .filter((o) => o.name) + .map((o) => ({ + name: o.name, + description: o.description, + dueDate: o.dueDate, + })); + await airtable.createMilestones(newAirtableProjectId, milestones); + + // Create assignments + await airtable.createAssignments(newAirtableProjectId, data.roles); + + const airtableUrl = getRecordUrl(newAirtableProjectId, 'projects'); + updateCreatedResources({ airtableProjectId: newAirtableProjectId, airtableUrl }); + } + } catch (err) { + debugLogger.log('error', 'Airtable creation/update failed', err); + setSubmitError(`Airtable: ${(err as Error).message}`); + } + } + + // 5. Update Airtable record with resource URLs (if we have an Airtable record) + const airtableProjectIdToUpdate = newAirtableProjectId || createdResources.airtableProjectId; + if (connectionStatus.airtable && airtableProjectIdToUpdate) { + try { + const f = airtableProjectFields; + const tableName = airtableTables.projects || 'Projects'; + const urlUpdates: Record = {}; + + // Get the URLs from local variables (just created) or state (existing) + const asanaUrl = newAsanaProjectGid ? asana.getProjectUrl(newAsanaProjectGid) : createdResources.asanaUrl; + const scopingDocUrl = newScopingDocUrl || createdResources.scopingDocUrl; + const folderUrl = newFolderUrl || createdResources.folderUrl; + + if (asanaUrl) { + urlUpdates[f.asana_url || 'Asana Board'] = asanaUrl; + } + if (scopingDocUrl) { + urlUpdates[f.scoping_doc_url || 'Project Scope'] = scopingDocUrl; + } + if (folderUrl) { + urlUpdates[f.folder_url || 'Project Folder'] = folderUrl; + } + + if (Object.keys(urlUpdates).length > 0) { + debugLogger.log('form', 'Updating Airtable with resource URLs', { airtableProjectIdToUpdate, urlUpdates }); + await airtable.updateRecord(tableName, airtableProjectIdToUpdate, urlUpdates); + } + } catch (err) { + debugLogger.log('error', 'Failed to update Airtable with resource URLs', err); + // Don't fail the whole operation for this + } + } + } finally { + setLoadingAction(null); + setPendingResolution(null); + } + }; + + // Handle "Create All Resources" button click + const handleCreateAllResources = async (): Promise => { + console.log('[DuplicateCheck] Starting, enabled:', duplicateCheckEnabled); + + if (!duplicateCheckEnabled) { + console.log('[DuplicateCheck] Disabled, skipping check'); + await executeCreateAll(); + return; + } + + setLoadingAction('duplicateCheck'); + setSubmitError(null); + + try { + const data = getFormData(); + console.log('[DuplicateCheck] Checking for:', data.projectName); + + const result = await checkDuplicates({ + projectName: data.projectName, + workspaceGid: integrationsConfig?.asana?.workspace_gid || import.meta.env.VITE_ASANA_WORKSPACE_GID, + sharedDriveId: import.meta.env.VITE_GOOGLE_SHARED_DRIVE_ID, + parentFolderId: import.meta.env.VITE_GOOGLE_PARENT_FOLDER_ID, + existingUrls: { + asanaUrl: data.existingAsanaUrl || undefined, + scopingDocUrl: data.existingScopingDocUrl || undefined, + }, + }); + + console.log('[DuplicateCheck] Result:', result); + + if (result.hasDuplicates) { + console.log('[DuplicateCheck] Duplicates found, showing modal'); + setDuplicateModalOpen(true); + } else { + console.log('[DuplicateCheck] No duplicates, proceeding with creation'); + await executeCreateAll(); + } + } catch (err) { + console.error('[DuplicateCheck] Error:', err); + setSubmitError(`Duplicate check failed: ${(err as Error).message}`); + } finally { + if (!duplicateModalOpen) { + setLoadingAction(null); + } + } + }; + + // Handle resolution from duplicate modal + const handleDuplicateResolution = async (resolution: DuplicateResolution): Promise => { + setDuplicateModalOpen(false); + setPendingResolution(resolution); + await executeCreateAll(resolution); + }; + + // Handle "Check for Duplicates" button click (check only, no creation) + const handleCheckDuplicatesOnly = async (): Promise => { + console.log('[DuplicateCheck] Check only - starting'); + setLoadingAction('duplicateCheck'); + setSubmitError(null); + + try { + const data = getFormData(); + console.log('[DuplicateCheck] Checking for:', data.projectName); + + const result = await checkDuplicates({ + projectName: data.projectName, + workspaceGid: integrationsConfig?.asana?.workspace_gid || import.meta.env.VITE_ASANA_WORKSPACE_GID, + sharedDriveId: import.meta.env.VITE_GOOGLE_SHARED_DRIVE_ID, + parentFolderId: import.meta.env.VITE_GOOGLE_PARENT_FOLDER_ID, + existingUrls: { + asanaUrl: data.existingAsanaUrl || undefined, + scopingDocUrl: data.existingScopingDocUrl || undefined, + }, + }); + + console.log('[DuplicateCheck] Result:', result); + + // Always show modal with results (even if no duplicates found) + setDuplicateModalOpen(true); + } catch (err) { + console.error('[DuplicateCheck] Error:', err); + setSubmitError(`Duplicate check failed: ${(err as Error).message}`); + } finally { + setLoadingAction(null); + } + }; + + // === EXISTING PROJECT HANDLERS (Phase 2) === + + // Handle project selection from search + const handleSelectExistingProject = (projectId: string): void => { + setSelectedProjectId(projectId); + setPreviewModalOpen(true); + }; + + // Map Airtable role value back to form role key + const getRoleKeyFromAirtableValue = (airtableRole: string): string | null => { + // Reverse lookup: find form key for Airtable role value + for (const [formKey, airtableValue] of Object.entries(airtableRoleValues)) { + if (airtableValue === airtableRole) { + return formKey; + } + } + return null; + }; + + // Populate form with existing project data + const handlePopulateFromExisting = (data: PopulateFormData): void => { + const { project, milestones, assignments } = data; + + // Clear current form and resources + clearCreatedResources(); + + // Populate basic fields + setValue('projectName', project.name); + setValue('projectAcronym', project.acronym || ''); + setValue('description', project.description || ''); + setValue('objectives', project.objectives || ''); + setValue('startDate', project.startDate || ''); + setValue('endDate', project.endDate || ''); + setValue('projectType', project.projectType || ''); + + // Populate linked record fields (Airtable returns array of IDs) + if (project.funder && project.funder.length > 0) { + setValue('funder', project.funder[0]); + } + if (project.parentInitiative && project.parentInitiative.length > 0) { + setValue('parentInitiative', project.parentInitiative[0]); + } + + // Populate existing resource URLs + if (project.asanaUrl) { + setValue('existingAsanaUrl', project.asanaUrl); + } + if (project.scopingDocUrl) { + setValue('existingScopingDocUrl', project.scopingDocUrl); + } + + // Populate role assignments + // Reset all roles first + ROLE_TYPES.forEach(role => { + setValue(`roles.${role.key as keyof FormData['roles']}.memberId`, ''); + setValue(`roles.${role.key as keyof FormData['roles']}.fte`, ''); + }); + + // Map assignments to roles + for (const assignment of assignments) { + const roleKey = getRoleKeyFromAirtableValue(assignment.role); + if (roleKey) { + setValue(`roles.${roleKey as keyof FormData['roles']}.memberId`, assignment.teamMemberId); + if (assignment.fte !== undefined) { + setValue(`roles.${roleKey as keyof FormData['roles']}.fte`, String(assignment.fte)); + } + } + } + + // Populate outcomes/milestones using replace to avoid infinite loop + if (milestones.length > 0) { + replaceOutcomes(milestones.map(milestone => ({ + name: milestone.name, + description: milestone.description || '', + dueDate: milestone.dueDate || '', + }))); + } else { + // Add one empty outcome if no milestones + replaceOutcomes([{ name: '', description: '', dueDate: '' }]); + } + + // Track that we're editing an existing project + setEditingExistingProject(project.id); + + // Close modal + setPreviewModalOpen(false); + setSelectedProjectId(null); + + debugLogger.log('form', 'Form populated from existing project', { + projectId: project.id, + projectName: project.name, + milestonesCount: milestones.length, + assignmentsCount: assignments.length, + }); + }; + + // Clear existing project mode and reset form + const handleClearExistingProject = (): void => { + setEditingExistingProject(null); + // Reset form to defaults + Object.entries(DEFAULT_FORM_VALUES).forEach(([key, value]) => { + setValue(key as keyof FormData, value as FormData[keyof FormData]); + }); + clearCreatedResources(); + }; + // === DRAFT HANDLERS === // Save current form as draft @@ -715,6 +1404,36 @@ export default function ProjectForm() {
)} + {/* Start from existing project (Phase 2) */} + {!editingExistingProject && isConnected && ( +
+ +
+ )} + + {/* Editing existing project banner */} + {editingExistingProject && ( +
+
+ +
+

Editing from existing project

+

Form populated with data from Airtable

+
+
+ +
+ )} +
e.preventDefault()}> {/* Basics Section */} @@ -803,6 +1522,51 @@ export default function ProjectForm() {
+ + {/* Existing Resource Links */} +
+

+ Link Existing Resources (Optional) +

+

+ If resources already exist for this project, paste their URLs here to skip creation. +

+
+ + + {errors.existingScopingDocUrl && ( +

{errors.existingScopingDocUrl.message}

+ )} +
+ + + + {errors.existingAsanaUrl && ( +

{errors.existingAsanaUrl.message}

+ )} +
+
+
{/* Description Section */} @@ -930,18 +1694,32 @@ export default function ProjectForm() { {...register(`outcomes.${index}.description`)} /> - +
+ + + +
))} + + - {/* Kickoff Deck */} - + {/* Created Resources Status (shown after resources are created) */} + {Object.keys(createdResources).length > 0 && ( +
+

Created Resources

+
+ {createdResources.airtableUrl && ( +
+ + Airtable Record + + View + +
+ )} + {createdResources.asanaUrl && ( +
+ + Asana Project + + View + +
+ )} + {createdResources.scopingDocUrl && ( +
+ + Scoping Document + + View + +
+ )} + {createdResources.kickoffDeckUrl && ( +
+ + Kickoff Deck + + View + +
+ )} + {createdResources.folderUrl && ( +
+ + Google Drive Folder + + View + +
+ )} +
+
+ )} - {/* Airtable Records */} - - + {/* Post-submission Resource Management (Phase 4) */} + {/* Status and navigation */}
@@ -1173,6 +1999,34 @@ export default function ProjectForm() { teamMembers={teamMembers} isLoading={isShareLoading} /> + + {/* Duplicate Resolution Modal */} + {duplicateResult && ( + { + setDuplicateModalOpen(false); + setLoadingAction(null); + }} + onConfirm={handleDuplicateResolution} + checkResult={duplicateResult} + defaults={duplicateDefaults} + isLoading={loadingAction === 'createAll'} + allowGoogleRecreate={integrationsConfig?.duplicates?.google?.allow_recreate} + /> + )} + + {/* Project Preview Modal (Phase 2) */} + { + setPreviewModalOpen(false); + setSelectedProjectId(null); + }} + onConfirm={handlePopulateFromExisting} + projectId={selectedProjectId} + teamMembers={teamMembers} + />
); diff --git a/project-creation-app/src/pages/ReviewDraft.tsx b/project-creation-app/src/pages/ReviewDraft.tsx index 8366062..ae1d9f3 100644 --- a/project-creation-app/src/pages/ReviewDraft.tsx +++ b/project-creation-app/src/pages/ReviewDraft.tsx @@ -15,6 +15,7 @@ import { getTemplateGidForProjectType } from '../utils/asanaTemplates'; import { getConnectionStatus } from '../services/oauth'; import * as drafts from '../services/drafts'; import * as airtable from '../services/airtable'; +import { getRecordUrl } from '../services/airtable'; import * as asana from '../services/asana'; import * as google from '../services/google'; import { @@ -283,7 +284,7 @@ export default function ReviewDraft() { await airtable.createAssignments(projectId, roleAssignments); } - const airtableUrl = `https://airtable.com/${import.meta.env.VITE_AIRTABLE_BASE_ID}/${projectId}`; + const airtableUrl = getRecordUrl(projectId, 'projects'); setCreatedResources(prev => ({ ...prev, airtableProjectId: projectId, airtableUrl })); } diff --git a/project-creation-app/src/services/airtable.ts b/project-creation-app/src/services/airtable.ts index 3f3ce5b..41ff718 100644 --- a/project-creation-app/src/services/airtable.ts +++ b/project-creation-app/src/services/airtable.ts @@ -3,6 +3,8 @@ import { getValidToken } from './oauth'; import { debugLogger } from './debugLogger'; import { airtableTables, + airtableTableIds, + airtableViewIds, airtableProjectFields, airtableMilestoneFields, airtableAssignmentFields, @@ -17,6 +19,31 @@ import type { AirtableRecord, TeamMember, RoleAssignment, Funder, ParentInitiati const BASE_ID = import.meta.env.VITE_AIRTABLE_BASE_ID; const API_URL = 'https://api.airtable.com/v0'; +/** + * Build an Airtable record URL. + * Format: https://airtable.com/{baseId}/{tableId}/{viewId}/{recordId} + * Falls back gracefully if table/view IDs aren't configured. + */ +export function getRecordUrl(recordId: string, tableKey: string = 'projects'): string { + const tableId = airtableTableIds[tableKey]; + const viewId = airtableViewIds[tableKey]; + + // Table ID is required for working URLs + if (!tableId) { + console.warn(`[Airtable] No table ID configured for "${tableKey}". URL may not work.`); + const tableName = airtableTables[tableKey] || 'Projects'; + return `https://airtable.com/${BASE_ID}/${tableName}/${recordId}`; + } + + // Build URL with view ID if available + if (viewId) { + return `https://airtable.com/${BASE_ID}/${tableId}/${viewId}/${recordId}`; + } + + // Without view ID, Airtable will use the default view + return `https://airtable.com/${BASE_ID}/${tableId}/${recordId}`; +} + interface GetRecordsOptions { fields?: string[]; filterByFormula?: string; @@ -291,7 +318,8 @@ export async function createAssignments( }; if (fte !== undefined && fte !== null && fte !== '') { - fields[f.fte || 'FTE'] = parseFloat(fte); + // Convert percentage to decimal (e.g., 50 -> 0.5) for Airtable + fields[f.fte || 'FTE'] = parseFloat(fte) / 100; } records.push({ fields }); @@ -333,28 +361,283 @@ export async function updateRecord( return data as AirtableRecord; } -// Check if a project with this name already exists +// Check if a project with this name already exists (substring match) export async function checkProjectExists(projectName: string): Promise { const tableName = airtableTables.projects || 'Projects'; const nameField = airtableProjectFields.name || 'Project'; - debugLogger.log('airtable', 'Checking for existing project', { projectName }); + // Escape special characters for Airtable formula + const escapedName = projectName.replace(/"/g, '\\"').replace(/\\/g, '\\\\'); + + // Use FIND for case-insensitive substring match + // FIND returns 0 if not found, >0 if found + const filterFormula = `OR(FIND(LOWER("${escapedName}"), LOWER({${nameField}})) > 0, FIND(LOWER({${nameField}}), LOWER("${escapedName}")) > 0)`; + console.log('[Airtable Search] Filter formula (substring):', filterFormula); + + debugLogger.log('airtable', 'Checking for existing project', { projectName, filterFormula }); const records = await getRecords(tableName, { - filterByFormula: `{${nameField}} = "${projectName.replace(/"/g, '\\"')}"`, - maxRecords: 1, + filterByFormula: filterFormula, + maxRecords: 5, // Get up to 5 matches for substring + }); + + console.log('[Airtable Search] Results:', { + recordsFound: records.length, + records: records.map(r => ({ + id: r.id, + name: r.fields[nameField], + })), }); const result: ProjectExistsResult = { exists: records.length > 0, existingRecord: records[0] || null, - url: records[0] ? `https://airtable.com/${BASE_ID}/${records[0].id}` : null, + url: records[0] ? getRecordUrl(records[0].id, 'projects') : null, }; debugLogger.log('airtable', 'Duplicate check result', result); return result; } +// Search projects by name (substring match) - returns multiple results for selection +export interface ProjectSearchResult { + id: string; + name: string; + acronym?: string; + startDate?: string; + endDate?: string; + status?: string; + url: string; +} + +export async function searchProjects(searchTerm: string, maxResults: number = 10): Promise { + const tableName = airtableTables.projects || 'Projects'; + const f = airtableProjectFields; + const nameField = f.name || 'Project'; + + // Escape special characters for Airtable formula + const escapedTerm = searchTerm.replace(/"/g, '\\"').replace(/\\/g, '\\\\'); + + // Use FIND for case-insensitive substring match + const filterFormula = `OR(FIND(LOWER("${escapedTerm}"), LOWER({${nameField}})) > 0, FIND(LOWER({${nameField}}), LOWER("${escapedTerm}")) > 0)`; + + debugLogger.log('airtable', 'Searching projects', { searchTerm, filterFormula }); + + const records = await getRecords(tableName, { + fields: [ + nameField, + f.acronym || 'Project Acronym', + f.start_date || 'Start Date', + f.end_date || 'End Date', + f.status || 'Status', + ], + filterByFormula: filterFormula, + maxRecords: maxResults, + sort: [{ field: nameField, direction: 'asc' }], + }); + + return records.map(r => ({ + id: r.id, + name: r.fields[nameField] as string, + acronym: r.fields[f.acronym || 'Project Acronym'] as string | undefined, + startDate: r.fields[f.start_date || 'Start Date'] as string | undefined, + endDate: r.fields[f.end_date || 'End Date'] as string | undefined, + status: r.fields[f.status || 'Status'] as string | undefined, + url: getRecordUrl(r.id, 'projects'), + })); +} + +// Get a single project record by ID with full details +export interface FullProjectData { + id: string; + name: string; + acronym?: string; + description?: string; + objectives?: string; + startDate?: string; + endDate?: string; + status?: string; + funder?: string[]; + parentInitiative?: string[]; + projectType?: string; + asanaUrl?: string; + scopingDocUrl?: string; + folderUrl?: string; + url: string; +} + +export async function getProjectById(projectId: string): Promise { + const tableName = airtableTables.projects || 'Projects'; + const f = airtableProjectFields; + + try { + const data = await airtableRequest(`${encodeURIComponent(tableName)}/${projectId}`) as AirtableRecord; + + return { + id: data.id, + name: data.fields[f.name || 'Project'] as string, + acronym: data.fields[f.acronym || 'Project Acronym'] as string | undefined, + description: data.fields[f.description || 'Project Description'] as string | undefined, + objectives: data.fields[f.objectives || 'Objectives'] as string | undefined, + startDate: data.fields[f.start_date || 'Start Date'] as string | undefined, + endDate: data.fields[f.end_date || 'End Date'] as string | undefined, + status: data.fields[f.status || 'Status'] as string | undefined, + funder: data.fields[f.funder || 'Funder'] as string[] | undefined, + parentInitiative: data.fields[f.parent_initiative || 'Parent Initiative'] as string[] | undefined, + projectType: data.fields[f.project_type || 'Project Type'] as string | undefined, + asanaUrl: data.fields[f.asana_url || 'Asana Board'] as string | undefined, + scopingDocUrl: data.fields[f.scoping_doc_url || 'Project Scope'] as string | undefined, + folderUrl: data.fields[f.folder_url || 'Project Folder'] as string | undefined, + url: getRecordUrl(data.id, 'projects'), + }; + } catch (error) { + debugLogger.log('error', 'Failed to fetch project by ID', { projectId, error }); + return null; + } +} + +// Get milestones for a project +export interface MilestoneRecord { + id: string; + name: string; + description?: string; + dueDate?: string; +} + +export async function getProjectMilestones(projectId: string): Promise { + const tableName = airtableTables.milestones || 'Milestones'; + const f = airtableMilestoneFields; + const projectLinkField = f.project_link || 'Project'; + + // Filter by project link + const filterFormula = `FIND("${projectId}", ARRAYJOIN({${projectLinkField}})) > 0`; + + const records = await getRecords(tableName, { + fields: [ + f.name || 'Milestone', + f.description || 'Description', + f.due_date || 'Due Date', + ], + filterByFormula: filterFormula, + sort: [{ field: f.due_date || 'Due Date', direction: 'asc' }], + }); + + return records.map(r => ({ + id: r.id, + name: r.fields[f.name || 'Milestone'] as string, + description: r.fields[f.description || 'Description'] as string | undefined, + dueDate: r.fields[f.due_date || 'Due Date'] as string | undefined, + })); +} + +// Get assignments for a project +export interface AssignmentRecord { + id: string; + role: string; + teamMemberId: string; + fte?: number; +} + +export async function getProjectAssignments(projectId: string): Promise { + const tableName = airtableTables.assignments || 'Assignments'; + const f = airtableAssignmentFields; + const projectLinkField = f.project_link || 'Project'; + + // Filter by project link + const filterFormula = `FIND("${projectId}", ARRAYJOIN({${projectLinkField}})) > 0`; + + const records = await getRecords(tableName, { + fields: [ + f.role || 'Role', + f.team_member_link || 'Data Team Member', + f.fte || 'FTE', + ], + filterByFormula: filterFormula, + }); + + return records.map(r => ({ + id: r.id, + role: r.fields[f.role || 'Role'] as string, + teamMemberId: (r.fields[f.team_member_link || 'Data Team Member'] as string[])?.[0] || '', + fte: r.fields[f.fte || 'FTE'] as number | undefined, + })); +} + +// Parse Airtable URL to extract record ID +export function parseAirtableUrl(url: string): { recordId: string | null; baseId: string | null } { + // Airtable URLs: https://airtable.com/{baseId}/{tableId}/{viewId?}/{recordId} + // or: https://airtable.com/{baseId}/{tableId}/{recordId} + const match = url.match(/airtable\.com\/([^/]+)\/[^/]+(?:\/[^/]+)?\/([^/?]+)/); + if (match) { + return { baseId: match[1], recordId: match[2] }; + } + return { baseId: null, recordId: null }; +} + +// Get field options for single-select fields from Airtable metadata API +export interface FieldOption { + id: string; + name: string; + color?: string; +} + +export async function getFieldOptions(tableName: string, fieldName: string): Promise { + const token = await getAccessToken(); + + debugLogger.log('airtable', 'Fetching field options', { tableName, fieldName }); + + // Use the metadata API to get table schema + const response = await fetch(`https://api.airtable.com/v0/meta/bases/${BASE_ID}/tables`, { + headers: { + 'Authorization': `Bearer ${token}`, + }, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + const errorMsg = error.error?.message || `Failed to fetch metadata: ${response.status}`; + debugLogger.logApiResponse('airtable', 'meta/tables', error, new Error(errorMsg)); + throw new Error(errorMsg); + } + + const data = await response.json(); + const tables = data.tables as Array<{ name: string; fields: Array<{ name: string; type: string; options?: { choices?: FieldOption[] } }> }>; + + // Find the table + const table = tables.find(t => t.name === tableName); + if (!table) { + debugLogger.log('airtable', 'Table not found', { tableName, availableTables: tables.map(t => t.name) }); + return []; + } + + // Find the field + const field = table.fields.find(f => f.name === fieldName); + if (!field) { + debugLogger.log('airtable', 'Field not found', { fieldName, availableFields: table.fields.map(f => f.name) }); + return []; + } + + // Check if it's a single/multi-select field with choices + if (field.type !== 'singleSelect' && field.type !== 'multipleSelects') { + debugLogger.log('airtable', 'Field is not a select type', { fieldName, fieldType: field.type }); + return []; + } + + const choices = field.options?.choices || []; + debugLogger.log('airtable', 'Field options fetched', { fieldName, count: choices.length, choices }); + + return choices; +} + +// Get project type options specifically +export async function getProjectTypeOptions(): Promise { + const tableName = airtableTables.projects || 'Projects'; + const fieldName = airtableProjectFields.project_type || 'Project Type'; + + const options = await getFieldOptions(tableName, fieldName); + return options.map(opt => opt.name); +} + // Export config for use in other modules export { airtableProjectFields, airtableTables }; @@ -368,4 +651,11 @@ export default { createAssignments, updateRecord, checkProjectExists, + searchProjects, + getProjectById, + getProjectMilestones, + getProjectAssignments, + parseAirtableUrl, + getFieldOptions, + getProjectTypeOptions, }; diff --git a/project-creation-app/src/services/asana.ts b/project-creation-app/src/services/asana.ts index c93a180..f9f4945 100644 --- a/project-creation-app/src/services/asana.ts +++ b/project-creation-app/src/services/asana.ts @@ -327,14 +327,33 @@ export async function searchProjectByName( `/workspaces/${workspaceGid}/typeahead?resource_type=project&query=${encodeURIComponent(projectName)}&opt_fields=name,permalink_url` ); - const exactMatch = (results || []).find(p => - p.name.toLowerCase() === projectName.toLowerCase() - ); + // Log what we're comparing (trim to handle trailing spaces) + const searchTerm = projectName.toLowerCase().trim(); + console.log('[Asana Search] Looking for substring match:', { + searchTerm, + resultsCount: (results || []).length, + resultNames: (results || []).map(p => { + const projectName = p.name.toLowerCase().trim(); + return { + name: p.name, + nameTrimmed: p.name.trim(), + containsSearchTerm: projectName.includes(searchTerm), + searchTermContainsName: searchTerm.includes(projectName), + isMatch: projectName.includes(searchTerm) || searchTerm.includes(projectName), + }; + }), + }); + + // Substring match: either the search term is in the project name, or vice versa + const match = (results || []).find(p => { + const projectNameLower = p.name.toLowerCase().trim(); + return projectNameLower.includes(searchTerm) || searchTerm.includes(projectNameLower); + }); const result: ProjectSearchResult = { - exists: !!exactMatch, - existingProject: exactMatch || null, - url: exactMatch?.permalink_url || null, + exists: !!match, + existingProject: match || null, + url: match?.permalink_url || null, searchResults: (results || []).slice(0, 5).map(p => ({ name: p.name, gid: p.gid })), }; diff --git a/project-creation-app/src/services/duplicates.ts b/project-creation-app/src/services/duplicates.ts new file mode 100644 index 0000000..75e551e --- /dev/null +++ b/project-creation-app/src/services/duplicates.ts @@ -0,0 +1,268 @@ +// Duplicate detection orchestration service +// Checks all three platforms for existing projects before creation + +import { checkProjectExists } from './airtable'; +import { searchProjectByName } from './asana'; +import { searchDriveFolder } from './google'; +import { debugLogger } from './debugLogger'; +import type { + DuplicateCheckResult, + AirtableDuplicateResult, + AsanaDuplicateResult, + GoogleDuplicateResult, + DuplicateDefaults, +} from '../types'; +import type { DuplicatesConfig } from '../hooks/useConfig'; + +// Default resolution strategies (used if config is unavailable) +const DEFAULT_RESOLUTIONS: DuplicateDefaults = { + airtable: 'update', + asana: 'use_existing', + google: 'keep', +}; + +/** + * Get default resolution strategies from config or use fallback defaults + */ +export function getDefaultResolutions(config?: DuplicatesConfig): DuplicateDefaults { + return { + airtable: config?.defaults?.airtable || DEFAULT_RESOLUTIONS.airtable, + asana: config?.defaults?.asana || DEFAULT_RESOLUTIONS.asana, + google: config?.defaults?.google || DEFAULT_RESOLUTIONS.google, + }; +} + +/** + * Check Airtable for an existing project with the same name + */ +async function checkAirtableDuplicate(projectName: string): Promise { + try { + debugLogger.log('airtable', 'Starting Airtable duplicate check', { projectName }); + const result = await checkProjectExists(projectName); + debugLogger.log('airtable', 'Airtable search completed', { result }); + return { + found: result.exists, + record: result.existingRecord || undefined, + url: result.url || undefined, + projectName: result.existingRecord?.fields?.['Project'] as string | undefined, + createdTime: result.existingRecord?.createdTime, + }; + } catch (error) { + console.error('[DuplicateCheck] Airtable API error:', error); + debugLogger.log('error', 'Airtable duplicate check failed', { error, projectName }); + // Return not found on error - allow creation to proceed + return { found: false }; + } +} + +/** + * Check Asana for an existing project with the same name + */ +async function checkAsanaDuplicate( + projectName: string, + workspaceGid: string +): Promise { + try { + debugLogger.log('asana', 'Starting Asana duplicate check', { projectName, workspaceGid }); + const result = await searchProjectByName(projectName, workspaceGid); + debugLogger.log('asana', 'Asana search completed', { result }); + return { + found: result.exists, + project: result.existingProject || undefined, + url: result.url || undefined, + searchResults: result.searchResults, + }; + } catch (error) { + console.error('[DuplicateCheck] Asana API error:', error); + debugLogger.log('error', 'Asana duplicate check failed', { error, projectName, workspaceGid }); + // Return not found on error - allow creation to proceed + return { found: false }; + } +} + +/** + * Check Google Drive for an existing project folder with the same name + */ +async function checkGoogleDuplicate( + folderName: string, + sharedDriveId: string | null, + parentFolderId: string | null +): Promise { + try { + debugLogger.log('google', 'Starting Google Drive duplicate check', { folderName, sharedDriveId, parentFolderId }); + const folders = await searchDriveFolder(folderName, sharedDriveId, parentFolderId); + debugLogger.log('google', 'Google Drive search completed', { foldersFound: folders.length, folders }); + + // Find exact match by name + const exactMatch = folders.find( + f => f.name.toLowerCase() === folderName.toLowerCase() + ); + + if (exactMatch) { + return { + found: true, + folderId: exactMatch.id, + url: exactMatch.webViewLink, + folderName: exactMatch.name, + // TODO: Could fetch folder contents here if needed + documents: [], + }; + } + + return { found: false }; + } catch (error) { + console.error('[DuplicateCheck] Google Drive API error:', error); + debugLogger.log('error', 'Google Drive duplicate check failed', { error, folderName }); + // Return not found on error - allow creation to proceed + return { found: false }; + } +} + +/** + * Parameters for checking all platforms for duplicates + */ +export interface CheckAllDuplicatesParams { + projectName: string; + /** Asana workspace GID (required for Asana check) */ + workspaceGid?: string; + /** Google Shared Drive ID (optional, for scoped search) */ + sharedDriveId?: string | null; + /** Google parent folder ID (optional, for scoped search) */ + parentFolderId?: string | null; + /** Platforms to check (defaults to all) */ + platforms?: { + airtable?: boolean; + asana?: boolean; + google?: boolean; + }; + /** Existing resource URLs (skip check if provided) */ + existingUrls?: { + asanaUrl?: string; + scopingDocUrl?: string; + }; +} + +/** + * Check all configured platforms for existing projects in parallel + * + * @param params - Project name and platform-specific parameters + * @returns Combined duplicate check results from all platforms + */ +export async function checkAllDuplicates( + params: CheckAllDuplicatesParams +): Promise { + const { + projectName, + workspaceGid, + sharedDriveId = null, + parentFolderId = null, + platforms = { airtable: true, asana: true, google: true }, + existingUrls = {}, + } = params; + + // Log all parameters for debugging + console.log('[DuplicateCheck] checkAllDuplicates called with:', { + projectName, + workspaceGid: workspaceGid || '(not provided)', + sharedDriveId: sharedDriveId || '(not provided)', + parentFolderId: parentFolderId || '(not provided)', + platforms, + existingUrls, + }); + + debugLogger.log('session', 'Starting duplicate check across platforms', { + projectName, + platforms, + workspaceGid, + sharedDriveId, + parentFolderId, + existingUrls, + }); + + const startTime = Date.now(); + + // Check if user provided existing URLs (skip those platforms) + const hasExistingAsana = !!existingUrls.asanaUrl; + const hasExistingScopingDoc = !!existingUrls.scopingDocUrl; + + // Log which checks will run + const willRunAirtable = platforms.airtable !== false; + const willRunAsana = platforms.asana !== false && !!workspaceGid && !hasExistingAsana; + const willRunGoogle = platforms.google !== false && !hasExistingScopingDoc; + console.log('[DuplicateCheck] Will run checks:', { + willRunAirtable, + willRunAsana, + willRunGoogle, + skippedBecauseExisting: { + asana: hasExistingAsana, + google: hasExistingScopingDoc, + } + }); + + // Run all checks in parallel for performance + const [airtableResult, asanaResult, googleResult] = await Promise.all([ + willRunAirtable + ? checkAirtableDuplicate(projectName) + : Promise.resolve({ found: false } as AirtableDuplicateResult), + + // If user provided existing Asana URL, mark as "found" with the URL + hasExistingAsana + ? Promise.resolve({ + found: true, + url: existingUrls.asanaUrl, + userProvided: true, // Flag to indicate user provided this + } as AsanaDuplicateResult & { userProvided?: boolean }) + : willRunAsana + ? checkAsanaDuplicate(projectName, workspaceGid!) + : Promise.resolve({ found: false, skipped: true } as AsanaDuplicateResult), + + // If user provided existing Scoping Doc URL, mark as "found" with the URL + hasExistingScopingDoc + ? Promise.resolve({ + found: true, + url: existingUrls.scopingDocUrl, + userProvided: true, // Flag to indicate user provided this + } as GoogleDuplicateResult & { userProvided?: boolean }) + : willRunGoogle + ? checkGoogleDuplicate(projectName, sharedDriveId, parentFolderId) + : Promise.resolve({ found: false } as GoogleDuplicateResult), + ]); + + const result: DuplicateCheckResult = { + airtable: airtableResult, + asana: asanaResult, + google: googleResult, + hasDuplicates: airtableResult.found || asanaResult.found || googleResult.found, + checkedAt: Date.now(), + }; + + debugLogger.log('session', 'Duplicate check complete', { + duration: Date.now() - startTime, + hasDuplicates: result.hasDuplicates, + foundIn: { + airtable: airtableResult.found, + asana: asanaResult.found, + google: googleResult.found, + }, + userProvided: { + asana: hasExistingAsana, + google: hasExistingScopingDoc, + }, + }); + + return result; +} + +/** + * Check if duplicate checking is enabled in config + */ +export function isDuplicateCheckEnabled(config?: DuplicatesConfig): boolean { + // Enabled by default if config is missing + return config?.enabled !== false; +} + +export default { + checkAllDuplicates, + getDefaultResolutions, + isDuplicateCheckEnabled, +}; diff --git a/project-creation-app/src/types/index.ts b/project-creation-app/src/types/index.ts index cec9dcf..a7b948e 100644 --- a/project-creation-app/src/types/index.ts +++ b/project-creation-app/src/types/index.ts @@ -11,6 +11,7 @@ export interface Outcome { name: string; description: string; dueDate: string; + assignee?: string; // Team member ID - defaults to Project Coordinator if blank } export interface FormData { @@ -25,6 +26,9 @@ export interface FormData { funder?: string; parentInitiative?: string; projectType?: string; + // Existing resource URLs (optional - skip creation if provided) + existingScopingDocUrl?: string; + existingAsanaUrl?: string; } // ============================================================================= @@ -273,3 +277,116 @@ export interface ShareDraftModalProps { export interface HelpTooltipProps { helpFile: string; } + +// ============================================================================= +// Duplicate Detection Types +// ============================================================================= + +/** + * Result of checking for an existing project in Airtable + */ +export interface AirtableDuplicateResult { + found: boolean; + record?: AirtableRecord; + url?: string; + /** Project name from the found record */ + projectName?: string; + /** Created date of the found record */ + createdTime?: string; +} + +/** + * Result of searching for an existing project in Asana + */ +export interface AsanaDuplicateResult { + found: boolean; + project?: AsanaProject; + url?: string; + /** Other projects with similar names (for fuzzy matching future enhancement) */ + searchResults?: Array<{ name: string; gid: string }>; + /** True if the user provided this URL via the form (not detected) */ + userProvided?: boolean; + /** True if the check was skipped (e.g., no workspace GID) */ + skipped?: boolean; +} + +/** + * Result of searching for an existing folder in Google Drive + */ +export interface GoogleDuplicateResult { + found: boolean; + folderId?: string; + url?: string; + folderName?: string; + /** Documents found within the folder */ + documents?: Array<{ name: string; id: string; mimeType?: string }>; + /** True if the user provided this URL via the form (not detected) */ + userProvided?: boolean; +} + +/** + * Combined result of checking all platforms for duplicates + */ +export interface DuplicateCheckResult { + airtable: AirtableDuplicateResult; + asana: AsanaDuplicateResult; + google: GoogleDuplicateResult; + /** True if any platform found a potential duplicate */ + hasDuplicates: boolean; + /** Timestamp of when the check was performed */ + checkedAt: number; +} + +/** + * User's resolution choice for Airtable duplicates + * - update: Update the existing record with new data + * - create_new: Create a new record regardless of existing + * - skip: Skip Airtable creation entirely + */ +export type AirtableResolution = 'update' | 'create_new' | 'skip'; + +/** + * User's resolution choice for Asana duplicates + * - use_existing: Add/update milestones in existing project + * - create_new: Create a new Asana project from template + * - skip: Skip Asana creation entirely + */ +export type AsanaResolution = 'use_existing' | 'create_new' | 'skip'; + +/** + * User's resolution choice for Google Drive duplicates + * - keep: Leave existing folder/documents as-is, skip creation + * - skip: Explicitly skip Google Drive creation + * - recreate: Delete existing and recreate (future, hidden for now) + */ +export type GoogleResolution = 'keep' | 'skip' | 'recreate'; + +/** + * User's resolution choices for all platforms + */ +export interface DuplicateResolution { + airtable: AirtableResolution; + asana: AsanaResolution; + google: GoogleResolution; +} + +/** + * Default resolution strategies (configurable) + */ +export interface DuplicateDefaults { + airtable: AirtableResolution; + asana: AsanaResolution; + google: GoogleResolution; +} + +/** + * Props for the DuplicateResolutionModal component + */ +export interface DuplicateResolutionModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: (resolution: DuplicateResolution) => void; + checkResult: DuplicateCheckResult; + defaults: DuplicateDefaults; + isLoading?: boolean; +}