Skip to content

Commit

Permalink
Merge pull request #625 from panoratech/feat/microsoft-dynamics-conne…
Browse files Browse the repository at this point in the history
…ction

Feat/microsoft dynamics connection
  • Loading branch information
naelob authored Aug 7, 2024
2 parents 5611b91 + b48b551 commit bc00593
Show file tree
Hide file tree
Showing 7 changed files with 297 additions and 18 deletions.
11 changes: 10 additions & 1 deletion apps/magic-link/src/lib/ProviderModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ interface IBasicAuthFormData {
[key : string]: string
}

const domainFormats: { [key: string]: string } = {
microsoftdynamicssales: 'YOURORGNAME.api.crm12.dynamics.com',
};

const ProviderModal = () => {
const [selectedCategory, setSelectedCategory] = useState("All");
const [selectedProvider, setSelectedProvider] = useState<{
Expand Down Expand Up @@ -506,7 +510,12 @@ const ProviderModal = () => {
<form onSubmit={(e) => { e.preventDefault(); onDomainSubmit(); }}>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label className={errors.end_user_domain ? 'text-destructive' : ''}>Enter your domain for {`${selectedProvider?.provider.substring(0,1).toUpperCase()}${selectedProvider?.provider.substring(1)}`}</Label>
<Label className={errors.end_user_domain ? 'text-destructive' : ''}>
Enter your domain for {`${selectedProvider?.provider.substring(0,1).toUpperCase()}${selectedProvider?.provider.substring(1)}`}
{domainFormats[selectedProvider?.provider.toLowerCase()] && (
<span className="text-sm text-gray-500"> (e.g., {domainFormats[selectedProvider?.provider.toLowerCase()]})</span>
)}
</Label>
<Input
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Your domain"
Expand Down
12 changes: 6 additions & 6 deletions docs/ecommerce/quickstart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ description: "Read data from multiple Ecommerce platforms using a single API"
icon: "star"
---

## List files in a Ecommerce provider using Panora
## List products in a Ecommerce provider using Panora

<Check>
We assume for this tutorial that you have a valid Panora API Key, and a
Expand Down Expand Up @@ -32,13 +32,13 @@ icon: "star"
</CodeGroup>
</Step>

<Step title="List files in your Ecommerce:">
<Info>In this example, we will list files in a Ecommerce. Visit other sections of the documentation to find category-specific examples</Info>
<Step title="List products in your Ecommerce:">
<Info>In this example, we will list products in a Ecommerce. Visit other sections of the documentation to find category-specific examples</Info>
<CodeGroup>

```shell curl
curl --request GET \
--url https://api.panora.dev/filestorage/files \
--url https://api.panora.dev/ecommerce/products \
--header 'x-api-key: <api-key>' \
--header 'x-connection-token: <x-connection-token>'
```
Expand All @@ -50,7 +50,7 @@ icon: "star"
apiKey: process.env.API_KEY,
});

const result = await panora.filestorage.files.list({
const result = await panora.ecommerce.products.list({
xConnectionToken: "YOUR_USER_CONNECTION_TOKEN",
});

Expand All @@ -65,7 +65,7 @@ icon: "star"
api_key=os.getenv("API_KEY", ""),
)

res = panora.filestorage.files.list(x_connection_token="YOUR_USER_CONNECTION_TOKEN")
res = panora.ecommerce.products.list(x_connection_token="YOUR_USER_CONNECTION_TOKEN")

print(res)
```
Expand Down
13 changes: 10 additions & 3 deletions packages/api/src/@core/connections/connections.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export type StateDataType = {
linkedUserId: string;
providerName: string;
returnUrl?: string;
[key: string]: any;
};

export class BodyDataType {
Expand Down Expand Up @@ -81,15 +82,21 @@ export class ConnectionsController {
}

const stateData: StateDataType = JSON.parse(decodeURIComponent(state));
const { projectId, vertical, linkedUserId, providerName, returnUrl } =
stateData;
const {
projectId,
vertical,
linkedUserId,
providerName,
returnUrl,
resource,
} = stateData;

const service = this.categoryConnectionRegistry.getService(
vertical.toLowerCase(),
);
await service.handleCallBack(
providerName,
{ linkedUserId, projectId, code, otherParams },
{ linkedUserId, projectId, code, otherParams, resource },
'oauth2',
);
if (providerName == 'shopify') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { ZendeskConnectionService } from './services/zendesk/zendesk.service';
import { ZohoConnectionService } from './services/zoho/zoho.service';
import { WealthboxConnectionService } from './services/wealthbox/wealthbox.service';
import { AcceloConnectionService } from './services/accelo/accelo.service';
import { MicrosoftDynamicsSalesConnectionService } from './services/microsoftdynamicssales/microsoftdynamicssales.service';

@Module({
imports: [WebhookModule, BullQueueModule],
Expand All @@ -44,6 +45,7 @@ import { AcceloConnectionService } from './services/accelo/accelo.service';
TeamworkConnectionService,
WealthboxConnectionService,
AcceloConnectionService,
MicrosoftDynamicsSalesConnectionService,
],
exports: [CrmConnectionsService],
})
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
import { EncryptionService } from '@@core/@core-services/encryption/encryption.service';
import { EnvironmentService } from '@@core/@core-services/environment/environment.service';
import { LoggerService } from '@@core/@core-services/logger/logger.service';
import { PrismaService } from '@@core/@core-services/prisma/prisma.service';
import { RetryHandler } from '@@core/@core-services/request-retry/retry.handler';
import { ConnectionsStrategiesService } from '@@core/connections-strategies/connections-strategies.service';
import { ConnectionUtils } from '@@core/connections/@utils';
import {
AbstractBaseConnectionService,
OAuthCallbackParams,
PassthroughInput,
RefreshParams,
} from '@@core/connections/@utils/types';
import { PassthroughResponse } from '@@core/passthrough/types';
import { Injectable } from '@nestjs/common';
import {
AuthStrategy,
CONNECTORS_METADATA,
OAuth2AuthData,
providerToType,
} from '@panora/shared';
import axios from 'axios';
import { v4 as uuidv4 } from 'uuid';
import { ServiceRegistry } from '../registry.service';
import { URLSearchParams } from 'url';

export type MicrosoftDynamicsSalesOAuthResponse = {
access_token: string;
refresh_token: string;
token_type: string;
expires_in: number;
scope: string;
};

@Injectable()
export class MicrosoftDynamicsSalesConnectionService extends AbstractBaseConnectionService {
private readonly type: string;

constructor(
protected prisma: PrismaService,
private logger: LoggerService,
private env: EnvironmentService,
protected cryptoService: EncryptionService,
private registry: ServiceRegistry,
private cService: ConnectionsStrategiesService,
private connectionUtils: ConnectionUtils,
private retryService: RetryHandler,
) {
super(prisma, cryptoService);
this.logger.setContext(MicrosoftDynamicsSalesConnectionService.name);
this.registry.registerService('microsoftdynamicssales', this);
this.type = providerToType(
'microsoftdynamicssales',
'crm',
AuthStrategy.oauth2,
);
}

async passthrough(
input: PassthroughInput,
connectionId: string,
): Promise<PassthroughResponse> {
try {
const { headers } = input;
const config = await this.constructPassthrough(input, connectionId);

const connection = await this.prisma.connections.findUnique({
where: {
id_connection: connectionId,
},
});

config.headers['Authorization'] = `Basic ${Buffer.from(
`${this.cryptoService.decrypt(connection.access_token)}:`,
).toString('base64')}`;

config.headers = {
...config.headers,
...headers,
};

return await this.retryService.makeRequest(
{
method: config.method,
url: config.url,
data: config.data,
headers: config.headers,
},
'crm.microsoftdynamicssales.passthrough',
config.linkedUserId,
);
} catch (error) {
throw error;
}
}

async handleCallback(opts: OAuthCallbackParams) {
try {
const { linkedUserId, projectId, code, resource } = opts;
const isNotUnique = await this.prisma.connections.findFirst({
where: {
id_linked_user: linkedUserId,
provider_slug: 'microsoftdynamicssales',
vertical: 'crm',
},
});

const REDIRECT_URI = `${this.env.getPanoraBaseUrl()}/connections/oauth/callback`;

const CREDENTIALS = (await this.cService.getCredentials(
projectId,
this.type,
)) as OAuth2AuthData;

const formData = new URLSearchParams({
redirect_uri: REDIRECT_URI,
client_id: CREDENTIALS.CLIENT_ID,
client_secret: CREDENTIALS.CLIENT_SECRET,
code: code,
scope: `https://${resource}/.default offline_access`,
grant_type: 'authorization_code',
});
const res = await axios.post(
`https://login.microsoftonline.com/common/oauth2/v2.0/token`,
formData.toString(),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8',
},
},
);
const data: MicrosoftDynamicsSalesOAuthResponse = res.data;
this.logger.log(
'OAuth credentials : microsoftdynamicssales crm ' +
JSON.stringify(data),
);

let db_res;
const connection_token = uuidv4();

if (isNotUnique) {
db_res = await this.prisma.connections.update({
where: {
id_connection: isNotUnique.id_connection,
},
data: {
access_token: this.cryptoService.encrypt(data.access_token),
refresh_token: this.cryptoService.encrypt(data.refresh_token),
account_url: `https://${resource}`,
expiration_timestamp: new Date(
new Date().getTime() + Number(data.expires_in) * 1000,
),
status: 'valid',
created_at: new Date(),
},
});
} else {
db_res = await this.prisma.connections.create({
data: {
id_connection: uuidv4(),
connection_token: connection_token,
provider_slug: 'microsoftdynamicssales',
vertical: 'crm',
token_type: 'oauth2',
account_url: `https://${resource}`,
access_token: this.cryptoService.encrypt(data.access_token),
refresh_token: this.cryptoService.encrypt(data.refresh_token),
expiration_timestamp: new Date(
new Date().getTime() + Number(data.expires_in) * 1000,
),
status: 'valid',
created_at: new Date(),
projects: {
connect: { id_project: projectId },
},
linked_users: {
connect: {
id_linked_user: await this.connectionUtils.getLinkedUserId(
projectId,
linkedUserId,
),
},
},
},
});
}
return db_res;
} catch (error) {
throw error;
}
}
async handleTokenRefresh(opts: RefreshParams) {
try {
const { connectionId, refreshToken, projectId } = opts;
const REDIRECT_URI = `${this.env.getPanoraBaseUrl()}/connections/oauth/callback`;
const CREDENTIALS = (await this.cService.getCredentials(
projectId,
this.type,
)) as OAuth2AuthData;

const conn = await this.prisma.connections.findUnique({
where: {
id_connection: connectionId,
},
});

const formData = new URLSearchParams({
grant_type: 'refresh_token',
scope: `${conn.account_url}/.default offline_access`,
client_id: CREDENTIALS.CLIENT_ID,
client_secret: CREDENTIALS.CLIENT_SECRET,
refresh_token: this.cryptoService.decrypt(refreshToken),
redirect_uri: REDIRECT_URI,
});

const res = await axios.post(
`https://login.microsoftonline.com/common/oauth2/v2.0/token`,
formData.toString(),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8',
},
},
);
const data: MicrosoftDynamicsSalesOAuthResponse = res.data;
await this.prisma.connections.update({
where: {
id_connection: connectionId,
},
data: {
access_token: this.cryptoService.encrypt(data.access_token),
refresh_token: this.cryptoService.encrypt(data.refresh_token),
expiration_timestamp: new Date(
new Date().getTime() + Number(data.expires_in) * 1000,
),
},
});
this.logger.log('OAuth credentials updated : microsoftdynamicssales ');
} catch (error) {
throw error;
}
}
}
19 changes: 17 additions & 2 deletions packages/shared/src/authUrl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,10 @@ export const constructAuthUrl = async ({ projectId, linkedUserId, providerName,
baseRedirectURL = redirectUriIngress.value!;
}
const encodedRedirectUrl = encodeURIComponent(`${baseRedirectURL}/connections/oauth/callback`);
const state = encodeURIComponent(JSON.stringify({ projectId, linkedUserId, providerName, vertical, returnUrl }));
let state = encodeURIComponent(JSON.stringify({ projectId, linkedUserId, providerName, vertical, returnUrl }));
if (providerName == 'microsoftdynamicssales') {
state = encodeURIComponent(JSON.stringify({ projectId, linkedUserId, providerName, vertical, returnUrl, resource: additionalParams!.end_user_domain }));
}
// console.log('State : ', JSON.stringify({ projectId, linkedUserId, providerName, vertical, returnUrl }));
// console.log('encodedRedirect URL : ', encodedRedirectUrl);
// const vertical = findConnectorCategory(providerName);
Expand Down Expand Up @@ -166,7 +169,19 @@ const handleOAuth2Url = async (input: HandleOAuth2Url) => {
if (needsScope(providerName, vertical) && scopes) {
if(providerName === 'slack') {
params += `&scope=&user_scope=${encodeURIComponent(scopes)}`;
} else {
} else if (providerName == 'microsoftdynamicssales') {
const url = new URL(BASE_URL);
// Extract the base URL without parameters
const base = url.origin + url.pathname;
// Extract the resource parameter
const resource = url.searchParams.get('resource');
BASE_URL = base;
let b = `https://${resource}/.default`;
b += (' offline_access');
console.log("scopes is "+ b)
console.log("BASE URL is "+ BASE_URL)
params += `&scope=${encodeURIComponent(b)}`;
}else {
params += `&scope=${encodeURIComponent(scopes)}`;
}
}
Expand Down
Loading

0 comments on commit bc00593

Please sign in to comment.