Skip to content
This repository was archived by the owner on Nov 5, 2025. It is now read-only.

Commit 9cdae38

Browse files
Merge pull request #82 from Sigmapitech/feat/issue-79/(re)actions
2 parents 06aa51a + f0afe72 commit 9cdae38

File tree

18 files changed

+276
-189
lines changed

18 files changed

+276
-189
lines changed

HOW_TO_CONTRIBUTE.md

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
Here’s a concise `HOW_TO_CONTRIBUTE.md` for adding a new OAuth service to your project based on the Discord example:
2+
3+
---
4+
5+
# How to Contribute a New OAuth Service
6+
7+
This guide explains how to add a new OAuth service to our platform using the existing Discord integration as a reference.
8+
9+
## 1. Create a Config Model
10+
11+
In your service module, create a `Config` Pydantic model that defines the OAuth configuration:
12+
13+
```python
14+
from pydantic import BaseModel
15+
16+
class Config(BaseModel):
17+
service: str = "google"
18+
client_id: str
19+
client_secret: str
20+
auth_base: str = "..."
21+
token_url: str = "..."
22+
api_base: str = "..."
23+
api_resource: str = "..."
24+
profile_endpoint: str = "..."
25+
redirect_uri: str = "..."
26+
scope: str = "..."
27+
pkce: bool = True
28+
```
29+
30+
* Set `service` to a unique identifier for the platform.
31+
* Specify the authorization URL (`auth_base`), token URL (`token_url`), API endpoints, and scopes.
32+
* `redirect_uri` should point to your API route for handling the callback.
33+
34+
## 2. Instantiate the OAuth Provider
35+
36+
Use the shared `OAuthProvider` class:
37+
38+
```python
39+
from fastapi import APIRouter
40+
import pathlib
41+
from .oauth_base import OAuthProvider
42+
43+
router = APIRouter(prefix="/[my_service]", tags=["[my_service]"])
44+
45+
provider = OAuthProvider(
46+
package=__package__,
47+
config_model=Config,
48+
icon=(pathlib.Path(__file__).parent / "icon.svg").read_text()
49+
)
50+
```
51+
52+
* The `icon` will be displayed in the frontend service cards.
53+
54+
## 3. Add API Routes
55+
56+
Define FastAPI routes to handle connecting, authentication, token refresh, and user info:
57+
58+
```python
59+
@router.get("/connect")
60+
async def google_connect(token: str, platform: str):
61+
return await provider.connect(token, platform)
62+
63+
@router.get("/auth")
64+
async def google_auth(code: str, state: str, db=Depends(get_session)):
65+
return await provider.auth(code, state, db)
66+
67+
@router.get("/refresh")
68+
async def google_refresh(user=Depends(get_current_user), db=Depends(get_session)):
69+
return await provider.refresh(user, db)
70+
71+
@router.get("/me")
72+
async def google_me(user=Depends(get_current_user), db=Depends(get_session)):
73+
return await provider.me(user, db)
74+
```
75+
76+
* `/connect` initiates the OAuth connection.
77+
* `/auth` handles the callback from the OAuth provider.
78+
* `/refresh` refreshes the access token.
79+
* `/me` retrieves the current user’s profile info from the service.
80+
81+
## 4. Add Service Icon
82+
83+
Place an SVG icon named `icon.svg` in the service module folder. This will be displayed in the frontend cards for connecting services.
84+
85+
## 5. Test
86+
87+
1. Login in to the area
88+
2. Go to `/services`
89+
3. You should see you newly added service and be able to connect to it

back/app/db/models/__init__.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,15 @@
1+
from .interaction import Interaction
2+
from .oauth import OAuthToken
3+
from .service import Service
14
from .user import User
25
from .workflow import Workflow, WorkflowNode, WorkflowNodeConfig
36

4-
__all__ = ("User", "Workflow", "WorkflowNode", "WorkflowNodeConfig")
7+
__all__ = (
8+
"Interaction",
9+
"OAuthToken",
10+
"Service",
11+
"User",
12+
"Workflow",
13+
"WorkflowNode",
14+
"WorkflowNodeConfig",
15+
)

back/app/db/models/interaction.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from sqlalchemy import Column, Integer, String
2+
3+
from ..base import Base
4+
5+
6+
class Interaction(Base):
7+
__tablename__ = "interaction"
8+
9+
id = Column(Integer, primary_key=True)
10+
name = Column(String(64), nullable=False)

back/app/db/models/oauth.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
1-
from datetime import datetime
2-
3-
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text
1+
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String
42
from sqlalchemy.orm import relationship
53

64
from ..base import Base
75

86

9-
class UserToken(Base):
10-
__tablename__ = "user_tokens"
7+
class OAuthToken(Base):
8+
__tablename__ = "oauth_token"
119

1210
id = Column(Integer, primary_key=True)
13-
user_id = Column(ForeignKey("user.id"), nullable=False)
14-
service = Column(String(100), nullable=False) # e.g. "spotify"
15-
access_token = Column(Text, nullable=False)
16-
refresh_token = Column(Text, nullable=True)
11+
owner_id = Column(ForeignKey("user.id"), nullable=False)
12+
access_token = Column(String(128), nullable=False)
13+
refresh_token = Column(String(128), nullable=True)
14+
15+
service_id = Column(Integer, ForeignKey("service.id"), nullable=False)
16+
1717
scope = Column(String(512), nullable=True)
1818
expires_at = Column(DateTime, nullable=True)
1919

back/app/db/models/service.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from sqlalchemy import Column, Integer, String
2+
3+
from ..base import Base
4+
5+
6+
class Service(Base):
7+
__tablename__ = "service"
8+
9+
id = Column(Integer, primary_key=True, index=True)
10+
name = Column(String(32), index=True)

back/app/db/models/user.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,14 @@ class User(Base):
1010
__tablename__ = "user"
1111

1212
id = Column(Integer, primary_key=True, index=True)
13-
email = Column(String, unique=True, index=True, nullable=False)
14-
auth = Column(String, nullable=False)
15-
name = Column(String, nullable=False)
13+
name = Column(String(64), nullable=False)
14+
15+
email = Column(String(256), unique=True, index=True, nullable=False)
16+
auth = Column(String(256), nullable=False)
17+
1618
created_at = Column(DateTime, default=datetime.now)
1719
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
20+
1821
workflows = relationship(
1922
"Workflow",
2023
back_populates="owner",
@@ -23,7 +26,7 @@ class User(Base):
2326
)
2427

2528
tokens = relationship(
26-
"UserToken",
29+
"OAuthToken",
2730
back_populates="user",
2831
cascade="all, delete-orphan",
2932
passive_deletes=True,

back/app/db/models/workflow.py

Lines changed: 9 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ class Workflow(Base):
88
__tablename__ = "workflow"
99

1010
id = Column(Integer, primary_key=True, index=True)
11-
name = Column(String, index=True)
12-
description = Column(String, index=True, nullable=True)
11+
name = Column(String(32), index=True)
12+
13+
description = Column(String(512), index=True, nullable=True)
1314
owner_id = Column(Integer, ForeignKey("user.id", ondelete="CASCADE"))
1415

1516
nodes = relationship(
@@ -30,6 +31,8 @@ class WorkflowNode(Base):
3031
Integer, ForeignKey("workflow.id", ondelete="CASCADE")
3132
)
3233

34+
interaction_id = Column(Integer, ForeignKey("interaction.id"))
35+
3336
config = relationship(
3437
"WorkflowNodeConfig",
3538
back_populates="node",
@@ -66,37 +69,15 @@ class WorkflowNodeConfig(Base):
6669
node_id = Column(
6770
Integer, ForeignKey("workflow_node.id", ondelete="CASCADE")
6871
)
69-
key = Column(String, index=True)
70-
value = Column(String, index=True)
72+
73+
key = Column(String(32), index=True)
74+
value = Column(String(128), index=True)
7175

7276
node = relationship("WorkflowNode", back_populates="config")
7377

7478

7579
class WorkflowEdge(Base):
76-
"""
77-
Represents a directed connection between two WorkflowNode entities in the workflow graph.
78-
79-
This SQLAlchemy ORM model stores edges that originate from one node and point to another.
80-
A uniqueness constraint on `to_node_id` guarantees that a node can have at most one
81-
incoming edge, while a single node may emit multiple outgoing edges. Deleting a node
82-
cascades to its associated edges.
83-
84-
Attributes:
85-
id (int): Primary key identifier of the edge.
86-
from_node_id (int): Foreign key to the source WorkflowNode (workflow_node.id), cascades on delete.
87-
to_node_id (int): Foreign key to the target WorkflowNode (workflow_node.id), cascades on delete.
88-
Uniquely constrained to enforce at most one incoming edge per node.
89-
90-
Relationships:
91-
from_node (WorkflowNode): Source node; back-populates 'outgoing_edges'.
92-
to_node (WorkflowNode): Target node; back-populates 'incoming_edge'.
93-
94-
Constraints and behavior:
95-
- Directed edge: from_node -> to_node.
96-
- ondelete="CASCADE" ensures edges are removed when their associated nodes are deleted.
97-
- Unique to_node_id enforces in-degree <= 1, enabling a one-to-many (source->edges) and
98-
one-to-one (target<-edge) structure.
99-
"""
80+
"""Represents a directed connection between two WorkflowNode its graph."""
10081

10182
__tablename__ = "workflow_edge"
10283

back/app/routes/caldav/google.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from sqlalchemy.ext.asyncio import AsyncSession
1313

1414
from ...db import get_session
15-
from ...db.models.oauth import UserToken
15+
from ...db.models.oauth import OAuthToken
1616
from ...security.deps import get_current_user
1717
from ..oauth_base import OAuthProvider
1818

@@ -51,11 +51,11 @@ class Config(BaseModel):
5151
)
5252

5353

54-
async def _get_token(user_id: int, db: AsyncSession) -> UserToken:
54+
async def _get_token(user_id: int, db: AsyncSession) -> OAuthToken:
5555
token = await db.scalar(
56-
select(UserToken).where(
57-
UserToken.user_id == user_id,
58-
UserToken.service == provider.cfg.service,
56+
select(OAuthToken).where(
57+
OAuthToken.owner_id == user_id,
58+
OAuthToken.service == provider.cfg.service,
5959
)
6060
)
6161
if not token:

back/app/routes/gmail/gmail.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from sqlalchemy.ext.asyncio import AsyncSession
1212

1313
from ...db import get_session
14-
from ...db.models.oauth import UserToken
14+
from ...db.models.oauth import OAuthToken
1515
from ...security.deps import get_current_user
1616
from ..oauth_base import OAuthProvider
1717

@@ -91,10 +91,10 @@ def _b64url_decode(data: str) -> bytes:
9191

9292
async def _get_gmail_token(
9393
user_id: int, db: AsyncSession
94-
) -> Optional[UserToken]:
94+
) -> Optional[OAuthToken]:
9595
return await db.scalar(
96-
select(UserToken).where(
97-
UserToken.user_id == user_id, UserToken.service == "gmail"
96+
select(OAuthToken).where(
97+
OAuthToken.owner_id == user_id, OAuthToken.service == "gmail"
9898
)
9999
)
100100

back/app/routes/oauth_base.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
from ..config import get_package_config
1818
from ..db.crud import users
19-
from ..db.models.oauth import UserToken
19+
from ..db.models.oauth import OAuthToken
2020
from ..db.models.user import User
2121
from ..security.jwt import decode_access_token
2222

@@ -171,7 +171,7 @@ async def auth(self, code: str, state: str, db: AsyncSession):
171171

172172
tokens = resp.json()
173173

174-
token = UserToken(
174+
token = OAuthToken(
175175
user_id=user.id,
176176
service=self.cfg.service,
177177
access_token=tokens.get("access_token"),
@@ -207,9 +207,9 @@ async def auth(self, code: str, state: str, db: AsyncSession):
207207

208208
async def refresh(self, user: User, db: AsyncSession):
209209
token = await db.scalar(
210-
select(UserToken).where(
211-
UserToken.user_id == user.id,
212-
UserToken.service == self.cfg.service,
210+
select(OAuthToken).where(
211+
OAuthToken.owner_id == user.id,
212+
OAuthToken.service == self.cfg.service,
213213
)
214214
)
215215
if not token:
@@ -258,9 +258,9 @@ async def refresh(self, user: User, db: AsyncSession):
258258

259259
async def me(self, user: User, db: AsyncSession):
260260
token = await db.scalar(
261-
select(UserToken).where(
262-
UserToken.user_id == user.id,
263-
UserToken.service == self.cfg.service,
261+
select(OAuthToken).where(
262+
OAuthToken.owner_id == user.id,
263+
OAuthToken.service == self.cfg.service,
264264
)
265265
)
266266
if not token:

0 commit comments

Comments
 (0)