Skip to content

Commit 6789e92

Browse files
authored
Merge branch 'main' into chore/update-pvtr-version
2 parents 81139ce + 0bc56e8 commit 6789e92

35 files changed

Lines changed: 1264 additions & 356 deletions

.env.test

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
NODE_ENV=test
2+
DB_HOST=localhost
3+
DB_PORT=5434
4+
DB_USER=postgres
5+
DB_PASSWORD=example
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
#!/usr/bin/env bash
2+
3+
set -euo pipefail
4+
5+
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
6+
# shellcheck source=scripts/utils
7+
source "${REPO_ROOT}/scripts/utils"
8+
9+
: "${DB_HOST:?DB_HOST is required}"
10+
: "${DB_PORT:?DB_PORT is required}"
11+
: "${DB_USER:?DB_USER is required}"
12+
: "${DB_PASSWORD:?DB_PASSWORD is required}"
13+
14+
say "Applying Sequin bootstrap SQL..."
15+
docker run --rm --network host \
16+
-v "${REPO_ROOT}/scripts/scaffold/sequin/postgres-docker-entrypoint-initdb.d/create-sequin-database.sql:/bootstrap.sql:ro" \
17+
-e "PGPASSWORD=${DB_PASSWORD}" \
18+
postgres:14-alpine \
19+
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d postgres -v ON_ERROR_STOP=1 -f /bootstrap.sql
20+
21+
say "Recreating test_template..."
22+
docker run --rm --network host \
23+
-e "PGPASSWORD=${DB_PASSWORD}" \
24+
postgres:14-alpine \
25+
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d postgres -v ON_ERROR_STOP=1 \
26+
-c "DROP DATABASE IF EXISTS test_template;" \
27+
-c "CREATE DATABASE test_template;"
28+
29+
if [[ "${SKIP_FLYWAY_BUILD:-0}" == "1" ]]; then
30+
say "Using pre-built crowd_flyway image."
31+
else
32+
say "Building flyway image..."
33+
docker build -t crowd_flyway -f "${REPO_ROOT}/backend/src/database/Dockerfile.flyway" "${REPO_ROOT}/backend/src/database"
34+
fi
35+
36+
say "Migrating test_template..."
37+
docker run --rm --network host \
38+
-e "PGHOST=${DB_HOST}" \
39+
-e "PGPORT=${DB_PORT}" \
40+
-e "PGUSER=${DB_USER}" \
41+
-e "PGPASSWORD=${DB_PASSWORD}" \
42+
-e PGDATABASE=test_template \
43+
crowd_flyway
44+
45+
say "Test template database ready."

.github/workflows/server-tests.yml

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
name: Server Tests
2+
3+
on:
4+
pull_request:
5+
# Only trigger when relevant files change to save CI minutes
6+
paths-ignore:
7+
- 'frontend/**'
8+
- '**.md'
9+
- '.gitignore'
10+
- '.editorconfig'
11+
- '**/.eslintrc*'
12+
- '.prettierrc'
13+
- '.prettierignore'
14+
- 'LICENSE'
15+
16+
# Automatically cancel in-progress runs if you push new code to the same PR
17+
concurrency:
18+
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
19+
cancel-in-progress: true
20+
21+
permissions:
22+
contents: read
23+
actions: write # GHA cache for Flyway image layers
24+
25+
jobs:
26+
test:
27+
runs-on: ubuntu-latest
28+
timeout-minutes: 30
29+
30+
services:
31+
postgres:
32+
image: postgres:14-alpine
33+
command: postgres -c wal_level=logical
34+
env:
35+
POSTGRES_USER: postgres
36+
POSTGRES_PASSWORD: example
37+
POSTGRES_DB: postgres
38+
ports:
39+
- 5432:5432
40+
options: >-
41+
--health-cmd "pg_isready -U postgres"
42+
--health-interval 2s
43+
--health-timeout 5s
44+
--health-retries 15
45+
46+
env:
47+
NODE_ENV: test
48+
DB_HOST: localhost
49+
DB_PORT: 5432
50+
DB_USER: postgres
51+
DB_PASSWORD: example
52+
53+
steps:
54+
- name: Check out repository code
55+
uses: actions/checkout@v4
56+
57+
- name: Setup pnpm
58+
uses: pnpm/action-setup@v4
59+
60+
- name: Setup Node
61+
uses: actions/setup-node@v4
62+
with:
63+
node-version: 20
64+
cache: 'pnpm'
65+
66+
- name: Install dependencies
67+
run: pnpm i --frozen-lockfile
68+
69+
- name: Set up Docker Buildx
70+
uses: docker/setup-buildx-action@v3
71+
72+
- name: Build Flyway image
73+
uses: docker/build-push-action@v6
74+
with:
75+
context: backend/src/database
76+
file: backend/src/database/Dockerfile.flyway
77+
load: true
78+
tags: crowd_flyway
79+
cache-from: type=gha,scope=flyway
80+
cache-to: type=gha,mode=max,scope=flyway
81+
82+
- name: Prepare test database
83+
env:
84+
SKIP_FLYWAY_BUILD: 1
85+
run: ./.github/scripts/prepare-test-template-db.sh
86+
87+
- name: Run server tests
88+
run: pnpm test:server

backend/package.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,6 @@
9494
"dotenv": "8.2.0",
9595
"dotenv-expand": "^8.0.3",
9696
"emoji-dictionary": "^1.0.11",
97-
"erlpack": "^0.1.4",
9897
"express": "4.17.1",
9998
"express-oauth2-jwt-bearer": "^1.7.4",
10099
"express-rate-limit": "6.5.1",
@@ -133,7 +132,6 @@
133132
"uuid": "^9.0.0",
134133
"validator": "^13.7.0",
135134
"verify-github-webhook": "^1.0.1",
136-
"zlib-sync": "^0.1.8",
137135
"zod": "^4.3.6"
138136
},
139137
"private": true,

backend/src/api/public/openapi.yaml

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -779,8 +779,11 @@ paths:
779779
/organizations:
780780
get:
781781
operationId: getOrganization
782-
summary: Look up an organization by domain
783-
description: Find a verified organization by its primary domain.
782+
summary: Look up an organization by domain or name
783+
description: >
784+
Provide domain, name, or both. When both are provided, the domain and
785+
name must belong to the same organization. If multiple organizations
786+
match, the most active one is returned.
784787
tags:
785788
- Organizations
786789
security:
@@ -789,12 +792,20 @@ paths:
789792
parameters:
790793
- name: domain
791794
in: query
792-
required: true
795+
required: false
793796
description: Primary domain of the organization.
794797
schema:
795798
type: string
796799
minLength: 1
797800
example: linuxfoundation.org
801+
- name: name
802+
in: query
803+
required: false
804+
description: Exact display name of the organization.
805+
schema:
806+
type: string
807+
minLength: 1
808+
example: Linux Foundation
798809
responses:
799810
'200':
800811
description: Organization found.
@@ -805,13 +816,14 @@ paths:
805816
example:
806817
id: 550e8400-e29b-41d4-a716-446655440000
807818
name: Linux Foundation
819+
domain: linuxfoundation.org
808820
logo: https://example.com/logo.png
809821
'401':
810822
$ref: '#/components/responses/Unauthorized'
811823
'403':
812824
$ref: '#/components/responses/Forbidden'
813825
'404':
814-
description: No verified organization found for the given domain.
826+
description: No organization found for the given domain or name.
815827
content:
816828
application/json:
817829
schema:
@@ -876,15 +888,26 @@ paths:
876888
required:
877889
- id
878890
- name
891+
- domain
879892
properties:
880893
id:
881894
type: string
882895
format: uuid
883896
name:
884897
type: string
898+
domain:
899+
type: string
900+
description: Verified primary domain of the organization.
901+
logo:
902+
type:
903+
- string
904+
- 'null'
905+
description: URL of the organization logo.
885906
example:
886907
id: 550e8400-e29b-41d4-a716-446655440000
887908
name: Acme Corp
909+
domain: acme.com
910+
logo: https://example.com/logo.png
888911
'400':
889912
$ref: '#/components/responses/BadRequest'
890913
'401':
@@ -1218,6 +1241,7 @@ components:
12181241
- id
12191242
- organizationId
12201243
- organizationName
1244+
- organizationDomains
12211245
- jobTitle
12221246
- verified
12231247
- verifiedBy
@@ -1243,6 +1267,11 @@ components:
12431267
- string
12441268
- 'null'
12451269
description: URL of the organization logo.
1270+
organizationDomains:
1271+
type: array
1272+
items:
1273+
type: string
1274+
description: Verified primary domains for the organization, in alphabetical order.
12461275
jobTitle:
12471276
type:
12481277
- string
@@ -1531,13 +1560,17 @@ components:
15311560
required:
15321561
- id
15331562
- name
1563+
- domain
15341564
properties:
15351565
id:
15361566
type: string
15371567
format: uuid
15381568
name:
15391569
type: string
15401570
description: Display name of the organization.
1571+
domain:
1572+
type: string
1573+
description: Verified primary domain.
15411574
logo:
15421575
type: string
15431576
description: URL of the organization logo. Only present if available.

backend/src/api/public/v1/members/work-experiences/createMemberWorkExperience.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ export async function createMemberWorkExperience(req: Request, res: Response): P
117117
memberOrganizationIds: [data.organizationId],
118118
})
119119

120-
const orgsMap = await fetchManyMemberOrgsWithOrgData(qx, [memberId])
120+
const orgsMap = await fetchManyMemberOrgsWithOrgData(qx, [memberId], { withDomains: true })
121121
createdMo = (orgsMap.get(memberId) ?? []).find((mo) => mo.id === newMemberOrgId)
122122

123123
captureNewState(createdMo ?? null)

backend/src/api/public/v1/members/work-experiences/getMemberWorkExperiences.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export async function getMemberWorkExperiences(req: Request, res: Response): Pro
2727
throw new NotFoundError('Member not found')
2828
}
2929

30-
const orgsMap = await fetchManyMemberOrgsWithOrgData(qx, [memberId])
30+
const orgsMap = await fetchManyMemberOrgsWithOrgData(qx, [memberId], { withDomains: true })
3131
const workExperiences = groupMemberOrganizations(orgsMap.get(memberId) ?? []).map(
3232
toMemberWorkExperience,
3333
)

backend/src/api/public/v1/members/work-experiences/updateMemberWorkExperience.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ export async function updateMemberWorkExperience(req: Request, res: Response): P
120120
memberOrganizationIds: [data.organizationId],
121121
})
122122

123-
const orgsMap = await fetchManyMemberOrgsWithOrgData(qx, [memberId])
123+
const orgsMap = await fetchManyMemberOrgsWithOrgData(qx, [memberId], { withDomains: true })
124124

125125
const updatedMo = groupMemberOrganizations(orgsMap.get(memberId) ?? []).find(
126126
(mo) => mo.id === workExperienceId,

backend/src/api/public/v1/members/work-experiences/verifyMemberWorkExperience.ts

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,22 @@ export async function verifyMemberWorkExperience(req: Request, res: Response): P
5252
throw new NotFoundError('Work experience not found')
5353
}
5454

55+
// Stash org fields for response fallback when reject soft-deletes the row.
56+
const memberOrgsWithOrgDataBeforeChange = verified
57+
? []
58+
: ((
59+
await fetchManyMemberOrgsWithOrgData(qx, [memberId], {
60+
withDomains: true,
61+
})
62+
).get(memberId) ?? [])
63+
5564
const overlappingGroupedRows = getOverlappingGroupedMemberOrganizations(memberOrgs, memberOrg)
5665

57-
const memberOrgIdsToDelete = [
58-
workExperienceId,
59-
...overlappingGroupedRows.flatMap((row) => (row.id ? [row.id] : [])),
60-
]
66+
const overlappingRowsWithIds = overlappingGroupedRows.filter(
67+
(row): row is typeof row & { id: string } => !!row.id,
68+
)
69+
70+
const memberOrgIdsToDelete = [workExperienceId, ...overlappingRowsWithIds.map((row) => row.id)]
6171

6272
const verifiedUpdate = { verified, verifiedBy }
6373

@@ -78,9 +88,7 @@ export async function verifyMemberWorkExperience(req: Request, res: Response): P
7888
verifiedUpdate,
7989
)
8090

81-
for (const overlappingRow of overlappingGroupedRows.filter(
82-
(row): row is typeof row & { id: string } => !!row.id,
83-
)) {
91+
for (const overlappingRow of overlappingRowsWithIds) {
8492
await updateMemberOrganization(tx, memberId, overlappingRow.id, verifiedUpdate)
8593
}
8694
} else {
@@ -96,17 +104,26 @@ export async function verifyMemberWorkExperience(req: Request, res: Response): P
96104
})
97105
}
98106

99-
captureNewState(updatedMemberOrg ?? { ...memberOrg, verified, verifiedBy })
107+
captureNewState(updatedMemberOrg ?? { ...memberOrg, ...verifiedUpdate })
100108
}),
101109
)
102110

103-
const orgsMap = await fetchManyMemberOrgsWithOrgData(qx, [memberId])
111+
const orgsMap = await fetchManyMemberOrgsWithOrgData(qx, [memberId], {
112+
withDomains: true,
113+
})
114+
115+
const groupedMemberOrgs = groupMemberOrganizations(orgsMap.get(memberId) ?? [])
116+
const groupedMemberOrgsBeforeChange = groupMemberOrganizations(memberOrgsWithOrgDataBeforeChange)
117+
118+
const fallbackMo = groupedMemberOrgsBeforeChange.find((mo) => mo.id === workExperienceId)
104119

105120
const responseMo: IMemberRoleWithOrganization =
106-
groupMemberOrganizations(orgsMap.get(memberId) ?? []).find(
107-
(mo) => mo.id === workExperienceId,
108-
) ??
109-
({ ...memberOrg, ...updatedMemberOrg, verified, verifiedBy } as IMemberRoleWithOrganization)
121+
groupedMemberOrgs.find((mo) => mo.id === workExperienceId) ??
122+
(fallbackMo ? { ...fallbackMo, ...verifiedUpdate } : undefined)
123+
124+
if (!responseMo) {
125+
throw new NotFoundError('Work experience not found')
126+
}
110127

111128
ok(res, toMemberWorkExperience(responseMo))
112129
}

0 commit comments

Comments
 (0)