Skip to content

Commit 42b0a9a

Browse files
authored
Create new UNAFFILIATED_EMAIL flow for affiliation invitation (#3603)
1 parent 3413b4c commit 42b0a9a

File tree

21 files changed

+735
-201
lines changed

21 files changed

+735
-201
lines changed

auth-api/devops/vaults.gcp.env

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,4 @@ ENVIRONMENT_NAME="op://CD/$APP_ENV/base/DEPLOYMENT_ENV"
7171

7272
# web-url
7373
BUSINESS_REGISTRY_URL="op://web-url/$APP_ENV/business-registry-ui/BUSINESS_REGISTRY_URL"
74+
REGISTRY_HOME_URL="op://web-url/$APP_ENV/business/REGISTRY_HOME_URL"
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""Add UNAFFILIATED_EMAIL affiliation invitation type and make from_org_id nullable.
2+
3+
Revision ID: a1b2c3d4e5f6
4+
Revises: 57e4f388c6ed
5+
Create Date: 2026-02-06
6+
7+
"""
8+
from alembic import op
9+
import sqlalchemy as sa
10+
from sqlalchemy import Boolean, String
11+
12+
13+
# revision identifiers, used by Alembic.
14+
revision = 'a1b2c3d4e5f6'
15+
down_revision = '57e4f388c6ed'
16+
branch_labels = None
17+
depends_on = None
18+
19+
20+
def upgrade():
21+
op.alter_column('affiliation_invitation_types', 'code', type_=sa.String(20), existing_type=sa.String(15))
22+
op.alter_column('affiliation_invitations', 'type', type_=sa.String(20), existing_type=sa.String(15))
23+
24+
ait = sa.table('affiliation_invitation_types',
25+
sa.column('code', String),
26+
sa.column('description', String),
27+
sa.column('default', Boolean)
28+
)
29+
op.bulk_insert(ait,
30+
[
31+
{'code': 'UNAFFILIATED_EMAIL', 'description': 'Invitation sent to entity email when no org affiliation exists', 'default': False}
32+
])
33+
op.alter_column('affiliation_invitations', 'from_org_id', nullable=True)
34+
op.alter_column('affiliation_invitations', 'sender_id', nullable=True, existing_type=sa.Integer())
35+
36+
37+
def downgrade():
38+
op.alter_column('affiliation_invitations', 'sender_id', nullable=False, existing_type=sa.Integer())
39+
op.alter_column('affiliation_invitations', 'from_org_id', nullable=False)
40+
op.execute("DELETE FROM affiliation_invitation_types WHERE code = 'UNAFFILIATED_EMAIL'")
41+
op.alter_column('affiliation_invitations', 'type', type_=sa.String(15), existing_type=sa.String(20))
42+
op.alter_column('affiliation_invitation_types', 'code', type_=sa.String(15), existing_type=sa.String(20))

auth-api/src/auth_api/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ class _Config: # pylint: disable=too-few-public-methods
152152
EMAIL_TOKEN_SECRET_KEY = os.getenv("EMAIL_TOKEN_SECRET_KEY")
153153
TOKEN_EXPIRY_PERIOD = os.getenv("TOKEN_EXPIRY_PERIOD")
154154
AFFILIATION_TOKEN_EXPIRY_PERIOD_MINS = os.getenv("AFFILIATION_TOKEN_EXPIRY_PERIOD_MINS", "720")
155+
UNAFFILIATED_EMAIL_TOKEN_EXPIRY_PERIOD_MINS = os.getenv("UNAFFILIATED_EMAIL_TOKEN_EXPIRY_PERIOD_MINS", "10080")
155156
STAFF_ADMIN_EMAIL = os.getenv("STAFF_ADMIN_EMAIL")
156157

157158
# front end serves this image in this name.can be moved to openshift config as well..
@@ -312,6 +313,7 @@ class TestConfig(_Config): # pylint: disable=too-few-public-methods
312313
API_GW_CONSUMER_EMAIL = "test.all.mc@gov.bc.ca"
313314
WEB_APP_URL = "https://localhost.com"
314315
BUSINESS_REGISTRY_URL = "https://localhost.com"
316+
REGISTRY_HOME_URL = "https://localhost.com"
315317

316318

317319
class ProdConfig(_Config): # pylint: disable=too-few-public-methods

auth-api/src/auth_api/exceptions/errors.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ class Error(Enum):
6060
"The business specified for the affiliation invitation could not be found.",
6161
HTTPStatus.BAD_REQUEST,
6262
)
63+
AFFILIATION_ALREADY_EXISTS = (
64+
"An affiliation already exists for this business.",
65+
HTTPStatus.CONFLICT,
66+
)
6367
FAILED_INVITATION = "Failed to dispatch the invitation", HTTPStatus.INTERNAL_SERVER_ERROR
6468
FAILED_NOTIFICATION = "Failed to dispatch the notification", HTTPStatus.INTERNAL_SERVER_ERROR
6569
DELETE_FAILED_ONLY_OWNER = (

auth-api/src/auth_api/models/affiliation_invitation.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,11 @@ class AffiliationInvitation(BaseModel): # pylint: disable=too-many-instance-att
3636
__tablename__ = "affiliation_invitations"
3737

3838
id = Column(Integer, primary_key=True)
39-
from_org_id = Column(ForeignKey("orgs.id"), nullable=False, index=True)
39+
from_org_id = Column(ForeignKey("orgs.id"), nullable=True, index=True)
4040
to_org_id = Column(ForeignKey("orgs.id"), nullable=True, index=True)
4141
entity_id = Column(ForeignKey("entities.id"), nullable=False, index=True)
4242
affiliation_id = Column(ForeignKey("affiliations.id"), nullable=True, index=True)
43-
sender_id = Column(ForeignKey("users.id"), nullable=False)
43+
sender_id = Column(ForeignKey("users.id"), nullable=True)
4444
approver_id = Column(ForeignKey("users.id"), nullable=True)
4545
recipient_email = Column(String(8000), nullable=True)
4646
sent_date = Column(DateTime, nullable=False)
@@ -59,11 +59,18 @@ class AffiliationInvitation(BaseModel): # pylint: disable=too-many-instance-att
5959
to_org = relationship("Org", foreign_keys=[to_org_id], lazy="select")
6060
affiliation = relationship("Affiliation", foreign_keys=[affiliation_id], lazy="select")
6161

62+
def _get_expiry_minutes(self):
63+
"""Get expiry minutes based on invitation type."""
64+
config = get_named_config()
65+
if self.type == AffiliationInvitationTypeEnum.UNAFFILIATED_EMAIL.value:
66+
return int(config.UNAFFILIATED_EMAIL_TOKEN_EXPIRY_PERIOD_MINS)
67+
return int(config.AFFILIATION_TOKEN_EXPIRY_PERIOD_MINS)
68+
6269
@hybrid_property
6370
def expires_on(self):
6471
"""Calculate the expiry date based on the config value."""
6572
if self.invitation_status_code == InvitationStatuses.PENDING.value:
66-
return self.sent_date + timedelta(minutes=int(get_named_config().AFFILIATION_TOKEN_EXPIRY_PERIOD_MINS))
73+
return self.sent_date + timedelta(minutes=self._get_expiry_minutes())
6774
return None
6875

6976
@hybrid_property
@@ -75,9 +82,7 @@ def status(self):
7582
return self.invitation_status_code
7683

7784
if self.invitation_status_code == InvitationStatuses.PENDING.value:
78-
expiry_time = self.sent_date + timedelta(
79-
minutes=int(get_named_config().AFFILIATION_TOKEN_EXPIRY_PERIOD_MINS)
80-
)
85+
expiry_time = self.sent_date + timedelta(minutes=self._get_expiry_minutes())
8186
if current_time >= expiry_time:
8287
return InvitationStatuses.EXPIRED.value
8388
return self.invitation_status_code
@@ -91,7 +96,7 @@ def create_from_dict(cls, invitation_info: dict, user_id, affiliation_id=None):
9196
affiliation_invitation = AffiliationInvitation()
9297
affiliation_invitation.sender_id = user_id
9398
affiliation_invitation.affiliation_id = affiliation_id
94-
affiliation_invitation.from_org_id = invitation_info["fromOrgId"]
99+
affiliation_invitation.from_org_id = invitation_info.get("fromOrgId")
95100
affiliation_invitation.to_org_id = invitation_info.get("toOrgId")
96101
affiliation_invitation.entity_id = invitation_info["entityId"]
97102
affiliation_invitation.recipient_email = invitation_info.get("recipientEmail")

auth-api/src/auth_api/models/affiliation_invitation_type.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
It defines the different types of an Affiliation Invitation.
1717
"""
1818

19+
from sqlalchemy import Column, String
20+
1921
from .base_model import BaseCodeModel
2022

2123

@@ -24,6 +26,8 @@ class AffiliationInvitationType(BaseCodeModel): # pylint: disable=too-few-publi
2426

2527
__tablename__ = "affiliation_invitation_types"
2628

29+
code = Column(String(20), primary_key=True)
30+
2731
@classmethod
2832
def get_default_type(cls):
2933
"""Return the default type code for an Affiliation Invitation."""

auth-api/src/auth_api/models/dataclass.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from requests import Request
2121

2222
from auth_api.utils.enums import KeycloakGroupActions
23+
from auth_api.utils.serializable import Serializable
2324

2425

2526
@dataclass
@@ -209,6 +210,17 @@ class ProductReviewTask:
209210
external_source_id: str | None = None
210211

211212

213+
@dataclass
214+
class UnaffiliatedEmailInvitationData(Serializable):
215+
"""Data for sending an unaffiliated email invitation to the mailer queue."""
216+
217+
business_name: str
218+
email_addresses: str
219+
business_identifier: str
220+
token: str
221+
context_url: str
222+
223+
212224
@dataclass
213225
class AffiliationBase:
214226
"""Small class for searching in Names and LEAR."""

auth-api/src/auth_api/resources/v1/affiliation_invitation.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,21 @@ def post_affiliation_invitation():
9898
return response, status
9999

100100

101+
@bp.route("/unaffiliated/<string:business_identifier>", methods=["POST"])
102+
@cross_origin(origins="*", methods=["POST"])
103+
@_jwt.has_one_of_roles([Role.SYSTEM.value])
104+
def post_unaffiliated_invitation(business_identifier):
105+
"""Send an unaffiliated email invitation for the entity."""
106+
try:
107+
entity = EntityService.find_by_business_identifier(business_identifier, skip_auth=True)
108+
if not entity:
109+
return {"message": "Business not found."}, HTTPStatus.NOT_FOUND
110+
AffiliationInvitationService.send_unaffiliated_email_invitation(entity)
111+
return {}, HTTPStatus.CREATED
112+
except BusinessException as exception:
113+
return {"code": exception.code, "message": exception.message}, exception.status_code
114+
115+
101116
@bp.route("/<string:affiliation_invitation_id>", methods=["GET", "OPTIONS"])
102117
@cross_origin(origins="*", methods=["GET", "PATCH", "DELETE"])
103118
@_jwt.requires_auth

0 commit comments

Comments
 (0)